opencode-dux 1.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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
@@ -0,0 +1,1194 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { loadAgentPrompt, loadPluginConfig } from './loader';
6
+
7
+ // Test deepMerge indirectly through loadPluginConfig behavior
8
+ // since deepMerge is not exported
9
+
10
+ describe('loadPluginConfig', () => {
11
+ let tempDir: string;
12
+ let userConfigDir: string;
13
+ let originalEnv: typeof process.env;
14
+
15
+ beforeEach(() => {
16
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loader-test-'));
17
+ userConfigDir = path.join(tempDir, 'user-config');
18
+ originalEnv = { ...process.env };
19
+ // Isolate from real user config
20
+ delete process.env.OPENCODE_CONFIG_DIR;
21
+ process.env.XDG_CONFIG_HOME = userConfigDir;
22
+ });
23
+
24
+ afterEach(() => {
25
+ fs.rmSync(tempDir, { recursive: true, force: true });
26
+ process.env = originalEnv;
27
+ });
28
+
29
+ test('returns empty config when no config files exist', () => {
30
+ const projectDir = path.join(tempDir, 'project');
31
+ fs.mkdirSync(projectDir, { recursive: true });
32
+ const config = loadPluginConfig(projectDir);
33
+ expect(config).toEqual({});
34
+ });
35
+
36
+ test('loads project config from .opencode directory', () => {
37
+ const projectDir = path.join(tempDir, 'project');
38
+ const projectConfigDir = path.join(projectDir, '.opencode');
39
+ fs.mkdirSync(projectConfigDir, { recursive: true });
40
+ fs.writeFileSync(
41
+ path.join(projectConfigDir, 'opencode-dux.json'),
42
+ JSON.stringify({
43
+ agents: {
44
+ oracle: { model: 'test/model' },
45
+ },
46
+ }),
47
+ );
48
+
49
+ const config = loadPluginConfig(projectDir);
50
+ expect(config.agents?.oracle?.model).toBe('test/model');
51
+ });
52
+
53
+ test('loads scoringEngineVersion flag when configured', () => {
54
+ const projectDir = path.join(tempDir, 'project');
55
+ const projectConfigDir = path.join(projectDir, '.opencode');
56
+ fs.mkdirSync(projectConfigDir, { recursive: true });
57
+ fs.writeFileSync(
58
+ path.join(projectConfigDir, 'opencode-dux.json'),
59
+ JSON.stringify({
60
+ scoringEngineVersion: 'v2-shadow',
61
+ }),
62
+ );
63
+
64
+ const config = loadPluginConfig(projectDir);
65
+ expect(config.scoringEngineVersion).toBe('v2-shadow');
66
+ });
67
+
68
+ test('loads balanceProviderUsage flag when configured', () => {
69
+ const projectDir = path.join(tempDir, 'project');
70
+ const projectConfigDir = path.join(projectDir, '.opencode');
71
+ fs.mkdirSync(projectConfigDir, { recursive: true });
72
+ fs.writeFileSync(
73
+ path.join(projectConfigDir, 'opencode-dux.json'),
74
+ JSON.stringify({
75
+ balanceProviderUsage: true,
76
+ }),
77
+ );
78
+
79
+ const config = loadPluginConfig(projectDir);
80
+ expect(config.balanceProviderUsage).toBe(true);
81
+ });
82
+
83
+ test('loads autoUpdate flag when configured', () => {
84
+ const projectDir = path.join(tempDir, 'project');
85
+ const projectConfigDir = path.join(projectDir, '.opencode');
86
+ fs.mkdirSync(projectConfigDir, { recursive: true });
87
+ fs.writeFileSync(
88
+ path.join(projectConfigDir, 'opencode-dux.json'),
89
+ JSON.stringify({
90
+ autoUpdate: false,
91
+ }),
92
+ );
93
+
94
+ const config = loadPluginConfig(projectDir);
95
+ expect(config.autoUpdate).toBe(false);
96
+ });
97
+
98
+ test('loads manual plan structure when configured', () => {
99
+ const projectDir = path.join(tempDir, 'project');
100
+ const projectConfigDir = path.join(projectDir, '.opencode');
101
+ fs.mkdirSync(projectConfigDir, { recursive: true });
102
+ fs.writeFileSync(
103
+ path.join(projectConfigDir, 'opencode-dux.json'),
104
+ JSON.stringify({
105
+ manualPlan: {
106
+ orchestrator: {
107
+ primary: 'openai/gpt-5.5',
108
+ fallback1: 'anthropic/claude-opus-4-6',
109
+ fallback2: 'chutes/kimi-k2.5',
110
+ fallback3: 'opencode/gpt-5-nano',
111
+ },
112
+ oracle: {
113
+ primary: 'openai/gpt-5.5',
114
+ fallback1: 'anthropic/claude-opus-4-6',
115
+ fallback2: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
116
+ fallback3: 'opencode/gpt-5-nano',
117
+ },
118
+ designer: {
119
+ primary: 'openai/gpt-5.5',
120
+ fallback1: 'anthropic/claude-opus-4-6',
121
+ fallback2: 'chutes/kimi-k2.5',
122
+ fallback3: 'opencode/gpt-5-nano',
123
+ },
124
+ explorer: {
125
+ primary: 'openai/gpt-5.5',
126
+ fallback1: 'anthropic/claude-opus-4-6',
127
+ fallback2: 'chutes/kimi-k2.5',
128
+ fallback3: 'opencode/gpt-5-nano',
129
+ },
130
+ librarian: {
131
+ primary: 'openai/gpt-5.5',
132
+ fallback1: 'anthropic/claude-opus-4-6',
133
+ fallback2: 'chutes/kimi-k2.5',
134
+ fallback3: 'opencode/gpt-5-nano',
135
+ },
136
+ fixer: {
137
+ primary: 'openai/gpt-5.5',
138
+ fallback1: 'anthropic/claude-opus-4-6',
139
+ fallback2: 'chutes/kimi-k2.5',
140
+ fallback3: 'opencode/gpt-5-nano',
141
+ },
142
+ },
143
+ }),
144
+ );
145
+
146
+ const config = loadPluginConfig(projectDir);
147
+ expect(config.manualPlan?.oracle?.fallback2).toBe(
148
+ 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
149
+ );
150
+ });
151
+
152
+ test('ignores invalid config (schema violation or malformed JSON)', () => {
153
+ const projectDir = path.join(tempDir, 'project');
154
+ const projectConfigDir = path.join(projectDir, '.opencode');
155
+ fs.mkdirSync(projectConfigDir, { recursive: true });
156
+
157
+ // Test 1: Invalid temperature (out of range)
158
+ fs.writeFileSync(
159
+ path.join(projectConfigDir, 'opencode-dux.json'),
160
+ JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
161
+ );
162
+ expect(loadPluginConfig(projectDir)).toEqual({});
163
+
164
+ // Test 2: Malformed JSON
165
+ fs.writeFileSync(
166
+ path.join(projectConfigDir, 'opencode-dux.json'),
167
+ '{ invalid json }',
168
+ );
169
+ expect(loadPluginConfig(projectDir)).toEqual({});
170
+ });
171
+
172
+ test('respects OPENCODE_CONFIG_DIR for user config location', () => {
173
+ const customDir = fs.mkdtempSync(
174
+ path.join(os.tmpdir(), 'omc-opencode-config-'),
175
+ );
176
+ process.env.OPENCODE_CONFIG_DIR = customDir;
177
+
178
+ // Write plugin config in the custom directory
179
+ fs.writeFileSync(
180
+ path.join(customDir, 'opencode-dux.json'),
181
+ JSON.stringify({
182
+ agents: { oracle: { model: 'custom/model-from-opencode-config-dir' } },
183
+ }),
184
+ );
185
+
186
+ const projectDir = path.join(tempDir, 'project');
187
+ fs.mkdirSync(projectDir, { recursive: true });
188
+
189
+ const config = loadPluginConfig(projectDir);
190
+ expect(config.agents?.oracle?.model).toBe(
191
+ 'custom/model-from-opencode-config-dir',
192
+ );
193
+
194
+ fs.rmSync(customDir, { recursive: true, force: true });
195
+ });
196
+
197
+ test('falls back to default user config dir when OPENCODE_CONFIG_DIR has no config', () => {
198
+ const customDir = fs.mkdtempSync(
199
+ path.join(os.tmpdir(), 'omc-opencode-config-empty-'),
200
+ );
201
+ process.env.OPENCODE_CONFIG_DIR = customDir;
202
+
203
+ const defaultConfigDir = path.join(userConfigDir, 'opencode');
204
+ fs.mkdirSync(defaultConfigDir, { recursive: true });
205
+ fs.writeFileSync(
206
+ path.join(defaultConfigDir, 'opencode-dux.json'),
207
+ JSON.stringify({
208
+ agents: { oracle: { model: 'fallback/default-config' } },
209
+ }),
210
+ );
211
+
212
+ const projectDir = path.join(tempDir, 'project');
213
+ fs.mkdirSync(projectDir, { recursive: true });
214
+
215
+ const config = loadPluginConfig(projectDir);
216
+ expect(config.agents?.oracle?.model).toBe('fallback/default-config');
217
+
218
+ fs.rmSync(customDir, { recursive: true, force: true });
219
+ });
220
+ });
221
+
222
+ describe('deepMerge behavior', () => {
223
+ let tempDir: string;
224
+ let userConfigDir: string;
225
+ let originalEnv: typeof process.env;
226
+
227
+ beforeEach(() => {
228
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-test-'));
229
+ userConfigDir = path.join(tempDir, 'user-config');
230
+ originalEnv = { ...process.env };
231
+
232
+ // Set XDG_CONFIG_HOME to control user config location
233
+ delete process.env.OPENCODE_CONFIG_DIR;
234
+ process.env.XDG_CONFIG_HOME = userConfigDir;
235
+ });
236
+
237
+ afterEach(() => {
238
+ fs.rmSync(tempDir, { recursive: true, force: true });
239
+ process.env = originalEnv;
240
+ });
241
+
242
+ test('merges nested agent configs from user and project', () => {
243
+ // Create user config
244
+ const userOpencodeDir = path.join(userConfigDir, 'opencode');
245
+ fs.mkdirSync(userOpencodeDir, { recursive: true });
246
+ fs.writeFileSync(
247
+ path.join(userOpencodeDir, 'opencode-dux.json'),
248
+ JSON.stringify({
249
+ agents: {
250
+ oracle: { model: 'user/oracle-model', temperature: 0.5 },
251
+ explorer: { model: 'user/explorer-model' },
252
+ },
253
+ }),
254
+ );
255
+
256
+ // Create project config (should override/merge with user)
257
+ const projectDir = path.join(tempDir, 'project');
258
+ const projectConfigDir = path.join(projectDir, '.opencode');
259
+ fs.mkdirSync(projectConfigDir, { recursive: true });
260
+ fs.writeFileSync(
261
+ path.join(projectConfigDir, 'opencode-dux.json'),
262
+ JSON.stringify({
263
+ agents: {
264
+ oracle: { temperature: 0.8 }, // Override temperature only
265
+ designer: { model: 'project/designer-model' }, // Add new agent
266
+ },
267
+ }),
268
+ );
269
+
270
+ const config = loadPluginConfig(projectDir);
271
+
272
+ // oracle: model from user, temperature from project
273
+ expect(config.agents?.oracle?.model).toBe('user/oracle-model');
274
+ expect(config.agents?.oracle?.temperature).toBe(0.8);
275
+
276
+ // explorer: from user only
277
+ expect(config.agents?.explorer?.model).toBe('user/explorer-model');
278
+
279
+ // designer: from project only
280
+ expect(config.agents?.designer?.model).toBe('project/designer-model');
281
+ });
282
+
283
+ test('handles missing user config gracefully', () => {
284
+ // Don't create user config, only project
285
+ const projectDir = path.join(tempDir, 'project');
286
+ const projectConfigDir = path.join(projectDir, '.opencode');
287
+ fs.mkdirSync(projectConfigDir, { recursive: true });
288
+ fs.writeFileSync(
289
+ path.join(projectConfigDir, 'opencode-dux.json'),
290
+ JSON.stringify({
291
+ agents: {
292
+ oracle: { model: 'project/model' },
293
+ },
294
+ }),
295
+ );
296
+
297
+ const config = loadPluginConfig(projectDir);
298
+ expect(config.agents?.oracle?.model).toBe('project/model');
299
+ });
300
+
301
+ test('handles missing project config gracefully', () => {
302
+ const userOpencodeDir = path.join(userConfigDir, 'opencode');
303
+ fs.mkdirSync(userOpencodeDir, { recursive: true });
304
+ fs.writeFileSync(
305
+ path.join(userOpencodeDir, 'opencode-dux.json'),
306
+ JSON.stringify({
307
+ agents: {
308
+ oracle: { model: 'user/model' },
309
+ },
310
+ }),
311
+ );
312
+
313
+ // No project config
314
+ const projectDir = path.join(tempDir, 'project');
315
+ fs.mkdirSync(projectDir, { recursive: true });
316
+
317
+ const config = loadPluginConfig(projectDir);
318
+ expect(config.agents?.oracle?.model).toBe('user/model');
319
+ });
320
+
321
+ test('merges fallback timeout and chains from user and project', () => {
322
+ const userOpencodeDir = path.join(userConfigDir, 'opencode');
323
+ fs.mkdirSync(userOpencodeDir, { recursive: true });
324
+ fs.writeFileSync(
325
+ path.join(userOpencodeDir, 'opencode-dux.json'),
326
+ JSON.stringify({
327
+ fallback: {
328
+ timeoutMs: 15000,
329
+ chains: {
330
+ oracle: ['openai/gpt-5.5', 'opencode/glm-4.7-free'],
331
+ },
332
+ },
333
+ }),
334
+ );
335
+
336
+ const projectDir = path.join(tempDir, 'project');
337
+ const projectConfigDir = path.join(projectDir, '.opencode');
338
+ fs.mkdirSync(projectConfigDir, { recursive: true });
339
+ fs.writeFileSync(
340
+ path.join(projectConfigDir, 'opencode-dux.json'),
341
+ JSON.stringify({
342
+ fallback: {
343
+ chains: {
344
+ explorer: ['google/antigravity-gemini-3-flash'],
345
+ },
346
+ },
347
+ }),
348
+ );
349
+
350
+ const config = loadPluginConfig(projectDir);
351
+ expect(config.fallback?.timeoutMs).toBe(15000);
352
+ expect(config.fallback?.chains.oracle).toEqual([
353
+ 'openai/gpt-5.5',
354
+ 'opencode/glm-4.7-free',
355
+ ]);
356
+ expect(config.fallback?.chains.explorer).toEqual([
357
+ 'google/antigravity-gemini-3-flash',
358
+ ]);
359
+ });
360
+
361
+ test('preserves fallback chains with additional agent keys', () => {
362
+ const projectDir = path.join(tempDir, 'project');
363
+ const projectConfigDir = path.join(projectDir, '.opencode');
364
+ fs.mkdirSync(projectConfigDir, { recursive: true });
365
+ fs.writeFileSync(
366
+ path.join(projectConfigDir, 'opencode-dux.json'),
367
+ JSON.stringify({
368
+ fallback: {
369
+ chains: {
370
+ writing: ['openai/gpt-5.5'],
371
+ },
372
+ },
373
+ }),
374
+ );
375
+
376
+ const config = loadPluginConfig(projectDir);
377
+ expect(config.fallback?.chains.writing).toEqual(['openai/gpt-5.5']);
378
+ });
379
+ });
380
+
381
+ describe('preset resolution', () => {
382
+ let tempDir: string;
383
+ let originalEnv: typeof process.env;
384
+
385
+ beforeEach(() => {
386
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-test-'));
387
+ originalEnv = { ...process.env };
388
+ delete process.env.OPENCODE_CONFIG_DIR;
389
+ process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
390
+ });
391
+
392
+ afterEach(() => {
393
+ fs.rmSync(tempDir, { recursive: true, force: true });
394
+ process.env = originalEnv;
395
+ });
396
+
397
+ test('backward compatibility: config with only agents works unchanged', () => {
398
+ const projectDir = path.join(tempDir, 'project');
399
+ const projectConfigDir = path.join(projectDir, '.opencode');
400
+ fs.mkdirSync(projectConfigDir, { recursive: true });
401
+ fs.writeFileSync(
402
+ path.join(projectConfigDir, 'opencode-dux.json'),
403
+ JSON.stringify({
404
+ agents: { oracle: { model: 'direct-model' } },
405
+ }),
406
+ );
407
+
408
+ const config = loadPluginConfig(projectDir);
409
+ expect(config.agents?.oracle?.model).toBe('direct-model');
410
+ expect(config.preset).toBeUndefined();
411
+ });
412
+
413
+ test("preset applied: preset + presets returns preset's agents", () => {
414
+ const projectDir = path.join(tempDir, 'project');
415
+ const projectConfigDir = path.join(projectDir, '.opencode');
416
+ fs.mkdirSync(projectConfigDir, { recursive: true });
417
+ fs.writeFileSync(
418
+ path.join(projectConfigDir, 'opencode-dux.json'),
419
+ JSON.stringify({
420
+ preset: 'fast',
421
+ presets: {
422
+ fast: { oracle: { model: 'fast-model' } },
423
+ },
424
+ }),
425
+ );
426
+
427
+ const config = loadPluginConfig(projectDir);
428
+ expect(config.agents?.oracle?.model).toBe('fast-model');
429
+ });
430
+
431
+ test('root agents override preset agents', () => {
432
+ const projectDir = path.join(tempDir, 'project');
433
+ const projectConfigDir = path.join(projectDir, '.opencode');
434
+ fs.mkdirSync(projectConfigDir, { recursive: true });
435
+ fs.writeFileSync(
436
+ path.join(projectConfigDir, 'opencode-dux.json'),
437
+ JSON.stringify({
438
+ preset: 'fast',
439
+ presets: {
440
+ fast: {
441
+ oracle: { model: 'fast-model', temperature: 0.1 },
442
+ explorer: { model: 'explorer-model' },
443
+ },
444
+ },
445
+ agents: {
446
+ oracle: { temperature: 0.9 }, // Should override preset temperature
447
+ },
448
+ }),
449
+ );
450
+
451
+ const config = loadPluginConfig(projectDir);
452
+ expect(config.agents?.oracle?.model).toBe('fast-model');
453
+ expect(config.agents?.oracle?.temperature).toBe(0.9);
454
+ expect(config.agents?.explorer?.model).toBe('explorer-model');
455
+ });
456
+
457
+ test('missing preset: preset set but not in presets -> returns empty/root agents', () => {
458
+ const projectDir = path.join(tempDir, 'project');
459
+ const projectConfigDir = path.join(projectDir, '.opencode');
460
+ fs.mkdirSync(projectConfigDir, { recursive: true });
461
+ fs.writeFileSync(
462
+ path.join(projectConfigDir, 'opencode-dux.json'),
463
+ JSON.stringify({
464
+ preset: 'nonexistent',
465
+ presets: {
466
+ other: { oracle: { model: 'other' } },
467
+ },
468
+ agents: { oracle: { model: 'root' } },
469
+ }),
470
+ );
471
+
472
+ const config = loadPluginConfig(projectDir);
473
+ expect(config.agents?.oracle?.model).toBe('root');
474
+ });
475
+
476
+ test('preset only: no root agents, just preset works', () => {
477
+ const projectDir = path.join(tempDir, 'project');
478
+ const projectConfigDir = path.join(projectDir, '.opencode');
479
+ fs.mkdirSync(projectConfigDir, { recursive: true });
480
+ fs.writeFileSync(
481
+ path.join(projectConfigDir, 'opencode-dux.json'),
482
+ JSON.stringify({
483
+ preset: 'dev',
484
+ presets: {
485
+ dev: { oracle: { model: 'dev-model' } },
486
+ },
487
+ }),
488
+ );
489
+
490
+ const config = loadPluginConfig(projectDir);
491
+ expect(config.agents?.oracle?.model).toBe('dev-model');
492
+ });
493
+
494
+ test('invalid preset shape: bad agent config in preset fails schema validation', () => {
495
+ const projectDir = path.join(tempDir, 'project');
496
+ const projectConfigDir = path.join(projectDir, '.opencode');
497
+ fs.mkdirSync(projectConfigDir, { recursive: true });
498
+
499
+ // preset agents with invalid temperature
500
+ fs.writeFileSync(
501
+ path.join(projectConfigDir, 'opencode-dux.json'),
502
+ JSON.stringify({
503
+ preset: 'invalid',
504
+ presets: {
505
+ invalid: { oracle: { temperature: 5 } },
506
+ },
507
+ }),
508
+ );
509
+
510
+ // Should return empty config due to validation failure
511
+ expect(loadPluginConfig(projectDir)).toEqual({});
512
+ });
513
+
514
+ test('nonexistent preset from config warns and falls back to root agents', () => {
515
+ const projectDir = path.join(tempDir, 'project');
516
+ const projectConfigDir = path.join(projectDir, '.opencode');
517
+ fs.mkdirSync(projectConfigDir, { recursive: true });
518
+ fs.writeFileSync(
519
+ path.join(projectConfigDir, 'opencode-dux.json'),
520
+ JSON.stringify({
521
+ preset: 'nonexistent',
522
+ presets: {
523
+ other: { oracle: { model: 'other' } },
524
+ },
525
+ agents: { oracle: { model: 'root' } },
526
+ }),
527
+ );
528
+
529
+ const consoleWarnSpy = spyOn(console, 'warn');
530
+ const config = loadPluginConfig(projectDir);
531
+ expect(config.agents?.oracle?.model).toBe('root');
532
+ expect(consoleWarnSpy).toHaveBeenCalled();
533
+ const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
534
+ expect(warningMessage).toContain('Preset "nonexistent" not found');
535
+ expect(warningMessage).toContain('Available presets: other');
536
+ });
537
+
538
+ test('nonexistent preset with no root agents returns empty agents', () => {
539
+ const projectDir = path.join(tempDir, 'project');
540
+ const projectConfigDir = path.join(projectDir, '.opencode');
541
+ fs.mkdirSync(projectConfigDir, { recursive: true });
542
+ fs.writeFileSync(
543
+ path.join(projectConfigDir, 'opencode-dux.json'),
544
+ JSON.stringify({
545
+ preset: 'nonexistent',
546
+ presets: {
547
+ other: { oracle: { model: 'other' } },
548
+ },
549
+ }),
550
+ );
551
+
552
+ const consoleWarnSpy = spyOn(console, 'warn');
553
+ const config = loadPluginConfig(projectDir);
554
+ expect(config.agents).toBeUndefined();
555
+ expect(consoleWarnSpy).toHaveBeenCalled();
556
+ const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
557
+ expect(warningMessage).toContain('Preset "nonexistent" not found');
558
+ });
559
+
560
+ test('options from preset are deep-merged with root agents', () => {
561
+ const projectDir = path.join(tempDir, 'project');
562
+ const projectConfigDir = path.join(projectDir, '.opencode');
563
+ fs.mkdirSync(projectConfigDir, { recursive: true });
564
+ fs.writeFileSync(
565
+ path.join(projectConfigDir, 'opencode-dux.json'),
566
+ JSON.stringify({
567
+ preset: 'openai',
568
+ presets: {
569
+ openai: {
570
+ oracle: {
571
+ model: 'openai/gpt-5.5',
572
+ options: { textVerbosity: 'low' },
573
+ },
574
+ },
575
+ },
576
+ agents: {
577
+ oracle: {
578
+ options: { reasoningEffort: 'medium' },
579
+ },
580
+ },
581
+ }),
582
+ );
583
+
584
+ const config = loadPluginConfig(projectDir);
585
+ expect(config.agents?.oracle?.model).toBe('openai/gpt-5.5');
586
+ // deepMerge should combine both option keys
587
+ expect(config.agents?.oracle?.options).toEqual({
588
+ textVerbosity: 'low',
589
+ reasoningEffort: 'medium',
590
+ });
591
+ });
592
+
593
+ test('options from preset only work without root agents', () => {
594
+ const projectDir = path.join(tempDir, 'project');
595
+ const projectConfigDir = path.join(projectDir, '.opencode');
596
+ fs.mkdirSync(projectConfigDir, { recursive: true });
597
+ fs.writeFileSync(
598
+ path.join(projectConfigDir, 'opencode-dux.json'),
599
+ JSON.stringify({
600
+ preset: 'anthropic-thinking',
601
+ presets: {
602
+ 'anthropic-thinking': {
603
+ oracle: {
604
+ model: 'anthropic/claude-sonnet-4-6',
605
+ options: {
606
+ thinking: { type: 'enabled', budgetTokens: 16000 },
607
+ },
608
+ },
609
+ },
610
+ },
611
+ }),
612
+ );
613
+
614
+ const config = loadPluginConfig(projectDir);
615
+ expect(config.agents?.oracle?.model).toBe('anthropic/claude-sonnet-4-6');
616
+ expect(config.agents?.oracle?.options).toEqual({
617
+ thinking: { type: 'enabled', budgetTokens: 16000 },
618
+ });
619
+ });
620
+
621
+ test('root options override preset options for same key', () => {
622
+ const projectDir = path.join(tempDir, 'project');
623
+ const projectConfigDir = path.join(projectDir, '.opencode');
624
+ fs.mkdirSync(projectConfigDir, { recursive: true });
625
+ fs.writeFileSync(
626
+ path.join(projectConfigDir, 'opencode-dux.json'),
627
+ JSON.stringify({
628
+ preset: 'concise',
629
+ presets: {
630
+ concise: {
631
+ oracle: {
632
+ model: 'openai/gpt-5.5',
633
+ options: { textVerbosity: 'low' },
634
+ },
635
+ },
636
+ },
637
+ agents: {
638
+ oracle: {
639
+ options: { textVerbosity: 'high' },
640
+ },
641
+ },
642
+ }),
643
+ );
644
+
645
+ const config = loadPluginConfig(projectDir);
646
+ expect(config.agents?.oracle?.model).toBe('openai/gpt-5.5');
647
+ // root wins over preset for same key
648
+ expect(config.agents?.oracle?.options).toEqual({
649
+ textVerbosity: 'high',
650
+ });
651
+ });
652
+ });
653
+
654
+ describe('environment variable preset override', () => {
655
+ let tempDir: string;
656
+ let originalEnv: typeof process.env;
657
+
658
+ beforeEach(() => {
659
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-preset-test-'));
660
+ originalEnv = { ...process.env };
661
+ delete process.env.OPENCODE_CONFIG_DIR;
662
+ process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
663
+ });
664
+
665
+ afterEach(() => {
666
+ fs.rmSync(tempDir, { recursive: true, force: true });
667
+ process.env = originalEnv;
668
+ });
669
+
670
+ test('Env var overrides preset from config file', () => {
671
+ const projectDir = path.join(tempDir, 'project');
672
+ const projectConfigDir = path.join(projectDir, '.opencode');
673
+ fs.mkdirSync(projectConfigDir, { recursive: true });
674
+ fs.writeFileSync(
675
+ path.join(projectConfigDir, 'opencode-dux.json'),
676
+ JSON.stringify({
677
+ preset: 'config-preset',
678
+ presets: {
679
+ 'config-preset': { oracle: { model: 'config-model' } },
680
+ 'env-preset': { oracle: { model: 'env-model' } },
681
+ },
682
+ }),
683
+ );
684
+
685
+ process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
686
+ const config = loadPluginConfig(projectDir);
687
+ expect(config.preset).toBe('env-preset');
688
+ expect(config.agents?.oracle?.model).toBe('env-model');
689
+ });
690
+
691
+ test('Env var works when config has no preset', () => {
692
+ const projectDir = path.join(tempDir, 'project');
693
+ const projectConfigDir = path.join(projectDir, '.opencode');
694
+ fs.mkdirSync(projectConfigDir, { recursive: true });
695
+ fs.writeFileSync(
696
+ path.join(projectConfigDir, 'opencode-dux.json'),
697
+ JSON.stringify({
698
+ presets: {
699
+ 'env-preset': { oracle: { model: 'env-model' } },
700
+ },
701
+ }),
702
+ );
703
+
704
+ process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
705
+ const config = loadPluginConfig(projectDir);
706
+ expect(config.preset).toBe('env-preset');
707
+ expect(config.agents?.oracle?.model).toBe('env-model');
708
+ });
709
+
710
+ test('Env var is ignored if empty string', () => {
711
+ const projectDir = path.join(tempDir, 'project');
712
+ const projectConfigDir = path.join(projectDir, '.opencode');
713
+ fs.mkdirSync(projectConfigDir, { recursive: true });
714
+ fs.writeFileSync(
715
+ path.join(projectConfigDir, 'opencode-dux.json'),
716
+ JSON.stringify({
717
+ preset: 'config-preset',
718
+ presets: {
719
+ 'config-preset': { oracle: { model: 'config-model' } },
720
+ },
721
+ }),
722
+ );
723
+
724
+ process.env.OH_MY_OPENCODE_SLIM_PRESET = '';
725
+ const config = loadPluginConfig(projectDir);
726
+ expect(config.preset).toBe('config-preset');
727
+ expect(config.agents?.oracle?.model).toBe('config-model');
728
+ });
729
+
730
+ test('Env var is ignored if undefined', () => {
731
+ const projectDir = path.join(tempDir, 'project');
732
+ const projectConfigDir = path.join(projectDir, '.opencode');
733
+ fs.mkdirSync(projectConfigDir, { recursive: true });
734
+ fs.writeFileSync(
735
+ path.join(projectConfigDir, 'opencode-dux.json'),
736
+ JSON.stringify({
737
+ preset: 'config-preset',
738
+ presets: {
739
+ 'config-preset': { oracle: { model: 'config-model' } },
740
+ },
741
+ }),
742
+ );
743
+
744
+ delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
745
+ const config = loadPluginConfig(projectDir);
746
+ expect(config.preset).toBe('config-preset');
747
+ expect(config.agents?.oracle?.model).toBe('config-model');
748
+ });
749
+
750
+ test('Env var with nonexistent preset warns and falls back', () => {
751
+ const projectDir = path.join(tempDir, 'project');
752
+ const projectConfigDir = path.join(projectDir, '.opencode');
753
+ fs.mkdirSync(projectConfigDir, { recursive: true });
754
+ fs.writeFileSync(
755
+ path.join(projectConfigDir, 'opencode-dux.json'),
756
+ JSON.stringify({
757
+ preset: 'config-preset',
758
+ presets: {
759
+ 'config-preset': { oracle: { model: 'config-model' } },
760
+ },
761
+ agents: { oracle: { model: 'fallback' } },
762
+ }),
763
+ );
764
+
765
+ process.env.OH_MY_OPENCODE_SLIM_PRESET = 'typo-preset';
766
+ const consoleWarnSpy = spyOn(console, 'warn');
767
+ const config = loadPluginConfig(projectDir);
768
+ expect(config.preset).toBe('typo-preset');
769
+ expect(config.agents?.oracle?.model).toBe('fallback');
770
+ expect(consoleWarnSpy).toHaveBeenCalled();
771
+ const calls = consoleWarnSpy.mock.calls as string[][];
772
+ const warningMessage =
773
+ calls.find((call) => call[0]?.includes('typo-preset'))?.[0] || '';
774
+ expect(warningMessage).toContain('Preset "typo-preset" not found');
775
+ expect(warningMessage).toContain('environment variable');
776
+ expect(warningMessage).toContain('config-preset');
777
+ });
778
+ });
779
+
780
+ describe('JSONC config support', () => {
781
+ let tempDir: string;
782
+ let originalEnv: typeof process.env;
783
+
784
+ beforeEach(() => {
785
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonc-test-'));
786
+ originalEnv = { ...process.env };
787
+ delete process.env.OPENCODE_CONFIG_DIR;
788
+ process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
789
+ });
790
+
791
+ afterEach(() => {
792
+ fs.rmSync(tempDir, { recursive: true, force: true });
793
+ process.env = originalEnv;
794
+ });
795
+
796
+ test('loads .jsonc file with single-line comments', () => {
797
+ const projectDir = path.join(tempDir, 'project');
798
+ const projectConfigDir = path.join(projectDir, '.opencode');
799
+ fs.mkdirSync(projectConfigDir, { recursive: true });
800
+ fs.writeFileSync(
801
+ path.join(projectConfigDir, 'opencode-dux.jsonc'),
802
+ `{
803
+ // This is a comment
804
+ "agents": {
805
+ "oracle": { "model": "test/model" } // inline comment
806
+ }
807
+ }`,
808
+ );
809
+
810
+ const config = loadPluginConfig(projectDir);
811
+ expect(config.agents?.oracle?.model).toBe('test/model');
812
+ });
813
+
814
+ test('loads .jsonc file with multi-line comments', () => {
815
+ const projectDir = path.join(tempDir, 'project');
816
+ const projectConfigDir = path.join(projectDir, '.opencode');
817
+ fs.mkdirSync(projectConfigDir, { recursive: true });
818
+ fs.writeFileSync(
819
+ path.join(projectConfigDir, 'opencode-dux.jsonc'),
820
+ `{
821
+ /* Multi-line
822
+ comment block */
823
+ "agents": {
824
+ "explorer": { "model": "explorer-model" }
825
+ }
826
+ }`,
827
+ );
828
+
829
+ const config = loadPluginConfig(projectDir);
830
+ expect(config.agents?.explorer?.model).toBe('explorer-model');
831
+ });
832
+
833
+ test('loads .jsonc file with trailing commas', () => {
834
+ const projectDir = path.join(tempDir, 'project');
835
+ const projectConfigDir = path.join(projectDir, '.opencode');
836
+ fs.mkdirSync(projectConfigDir, { recursive: true });
837
+ fs.writeFileSync(
838
+ path.join(projectConfigDir, 'opencode-dux.jsonc'),
839
+ `{
840
+ "agents": {
841
+ "oracle": { "model": "test-model", },
842
+ },
843
+ }`,
844
+ );
845
+
846
+ const config = loadPluginConfig(projectDir);
847
+ expect(config.agents?.oracle?.model).toBe('test-model');
848
+ });
849
+
850
+ test('prefers .jsonc over .json when both exist', () => {
851
+ const projectDir = path.join(tempDir, 'project');
852
+ const projectConfigDir = path.join(projectDir, '.opencode');
853
+ fs.mkdirSync(projectConfigDir, { recursive: true });
854
+
855
+ // Create both files
856
+ fs.writeFileSync(
857
+ path.join(projectConfigDir, 'opencode-dux.json'),
858
+ JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
859
+ );
860
+ fs.writeFileSync(
861
+ path.join(projectConfigDir, 'opencode-dux.jsonc'),
862
+ `{
863
+ // JSONC version
864
+ "agents": { "oracle": { "model": "jsonc-model" } }
865
+ }`,
866
+ );
867
+
868
+ const config = loadPluginConfig(projectDir);
869
+ expect(config.agents?.oracle?.model).toBe('jsonc-model');
870
+ });
871
+
872
+ test('falls back to .json when .jsonc does not exist', () => {
873
+ const projectDir = path.join(tempDir, 'project');
874
+ const projectConfigDir = path.join(projectDir, '.opencode');
875
+ fs.mkdirSync(projectConfigDir, { recursive: true });
876
+
877
+ // Only create .json file
878
+ fs.writeFileSync(
879
+ path.join(projectConfigDir, 'opencode-dux.json'),
880
+ JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
881
+ );
882
+
883
+ const config = loadPluginConfig(projectDir);
884
+ expect(config.agents?.oracle?.model).toBe('json-model');
885
+ });
886
+
887
+ test('loads user config from .jsonc', () => {
888
+ const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
889
+ fs.mkdirSync(userOpencodeDir, { recursive: true });
890
+ fs.writeFileSync(
891
+ path.join(userOpencodeDir, 'opencode-dux.jsonc'),
892
+ `{
893
+ // User config with comments
894
+ "agents": { "librarian": { "model": "user-librarian" } }
895
+ }`,
896
+ );
897
+
898
+ const projectDir = path.join(tempDir, 'project');
899
+ fs.mkdirSync(projectDir, { recursive: true });
900
+
901
+ const config = loadPluginConfig(projectDir);
902
+ expect(config.agents?.librarian?.model).toBe('user-librarian');
903
+ });
904
+
905
+ test('merges user .jsonc with project .jsonc', () => {
906
+ const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
907
+ fs.mkdirSync(userOpencodeDir, { recursive: true });
908
+ fs.writeFileSync(
909
+ path.join(userOpencodeDir, 'opencode-dux.jsonc'),
910
+ `{
911
+ // User config
912
+ "agents": {
913
+ "oracle": { "model": "user-oracle", "temperature": 0.5 }
914
+ }
915
+ }`,
916
+ );
917
+
918
+ const projectDir = path.join(tempDir, 'project');
919
+ const projectConfigDir = path.join(projectDir, '.opencode');
920
+ fs.mkdirSync(projectConfigDir, { recursive: true });
921
+ fs.writeFileSync(
922
+ path.join(projectConfigDir, 'opencode-dux.jsonc'),
923
+ `{
924
+ // Project config
925
+ "agents": { "oracle": { "temperature": 0.8 } }
926
+ }`,
927
+ );
928
+
929
+ const config = loadPluginConfig(projectDir);
930
+ expect(config.agents?.oracle?.model).toBe('user-oracle');
931
+ expect(config.agents?.oracle?.temperature).toBe(0.8);
932
+ });
933
+
934
+ test('handles complex JSONC with mixed comments and trailing commas', () => {
935
+ const projectDir = path.join(tempDir, 'project');
936
+ const projectConfigDir = path.join(projectDir, '.opencode');
937
+ fs.mkdirSync(projectConfigDir, { recursive: true });
938
+ fs.writeFileSync(
939
+ path.join(projectConfigDir, 'opencode-dux.jsonc'),
940
+ `{
941
+ // Main configuration
942
+ "preset": "dev",
943
+ /* Presets definition */
944
+ "presets": {
945
+ "dev": {
946
+ // Development agents
947
+ "oracle": { "model": "dev-oracle", },
948
+ "explorer": { "model": "dev-explorer", },
949
+ },
950
+ },
951
+ }`,
952
+ );
953
+
954
+ const config = loadPluginConfig(projectDir);
955
+ expect(config.preset).toBe('dev');
956
+ expect(config.agents?.oracle?.model).toBe('dev-oracle');
957
+ expect(config.agents?.explorer?.model).toBe('dev-explorer');
958
+ });
959
+ });
960
+
961
+ describe('loadAgentPrompt', () => {
962
+ let tempDir: string;
963
+ let originalEnv: typeof process.env;
964
+
965
+ beforeEach(() => {
966
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-test-'));
967
+ originalEnv = { ...process.env };
968
+ delete process.env.OPENCODE_CONFIG_DIR;
969
+ process.env.XDG_CONFIG_HOME = tempDir;
970
+ });
971
+
972
+ afterEach(() => {
973
+ fs.rmSync(tempDir, { recursive: true, force: true });
974
+ process.env = originalEnv;
975
+ });
976
+
977
+ test('returns empty object when no prompt files exist', () => {
978
+ const result = loadAgentPrompt('oracle');
979
+ expect(result).toEqual({});
980
+ });
981
+
982
+ test('loads replacement prompt from {agent}.md', () => {
983
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
984
+ fs.mkdirSync(promptsDir, { recursive: true });
985
+ fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
986
+
987
+ const result = loadAgentPrompt('oracle');
988
+ expect(result.prompt).toBe('replacement prompt');
989
+ expect(result.appendPrompt).toBeUndefined();
990
+ });
991
+
992
+ test('loads append prompt from {agent}_append.md', () => {
993
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
994
+ fs.mkdirSync(promptsDir, { recursive: true });
995
+ fs.writeFileSync(
996
+ path.join(promptsDir, 'oracle_append.md'),
997
+ 'append prompt',
998
+ );
999
+
1000
+ const result = loadAgentPrompt('oracle');
1001
+ expect(result.prompt).toBeUndefined();
1002
+ expect(result.appendPrompt).toBe('append prompt');
1003
+ });
1004
+
1005
+ test('loads both replacement and append prompts', () => {
1006
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
1007
+ fs.mkdirSync(promptsDir, { recursive: true });
1008
+ fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
1009
+ fs.writeFileSync(
1010
+ path.join(promptsDir, 'oracle_append.md'),
1011
+ 'append prompt',
1012
+ );
1013
+
1014
+ const result = loadAgentPrompt('oracle');
1015
+ expect(result.prompt).toBe('replacement prompt');
1016
+ expect(result.appendPrompt).toBe('append prompt');
1017
+ });
1018
+
1019
+ test('handles file read errors gracefully', () => {
1020
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
1021
+ fs.mkdirSync(promptsDir, { recursive: true });
1022
+ const promptPath = path.join(promptsDir, 'error-agent.md');
1023
+ fs.writeFileSync(promptPath, 'content');
1024
+
1025
+ const consoleWarnSpy = spyOn(console, 'warn');
1026
+
1027
+ // Use a unique agent name and check for it specifically
1028
+ const originalReadFileSync = fs.readFileSync;
1029
+ const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
1030
+ ...args: Parameters<typeof fs.readFileSync>
1031
+ ) => {
1032
+ const [p] = args;
1033
+ if (typeof p === 'string' && p.includes('error-agent.md')) {
1034
+ throw new Error('Read error');
1035
+ }
1036
+ return originalReadFileSync(...args);
1037
+ }) as typeof fs.readFileSync);
1038
+
1039
+ try {
1040
+ const result = loadAgentPrompt('error-agent');
1041
+ expect(result.prompt).toBeUndefined();
1042
+
1043
+ const warningFound = consoleWarnSpy.mock.calls.some((call) =>
1044
+ (call[0] as string).includes('Error reading prompt file'),
1045
+ );
1046
+ expect(warningFound).toBe(true);
1047
+ } finally {
1048
+ readSpy.mockRestore();
1049
+ }
1050
+ });
1051
+
1052
+ test('prefers preset prompt files over root prompts', () => {
1053
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
1054
+ const presetDir = path.join(promptsDir, 'test');
1055
+ fs.mkdirSync(presetDir, { recursive: true });
1056
+
1057
+ fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
1058
+ fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
1059
+ fs.writeFileSync(
1060
+ path.join(promptsDir, 'oracle_append.md'),
1061
+ 'root append prompt',
1062
+ );
1063
+ fs.writeFileSync(
1064
+ path.join(presetDir, 'oracle_append.md'),
1065
+ 'preset append prompt',
1066
+ );
1067
+
1068
+ const result = loadAgentPrompt('oracle', 'test');
1069
+ expect(result.prompt).toBe('preset replacement');
1070
+ expect(result.appendPrompt).toBe('preset append prompt');
1071
+ });
1072
+
1073
+ test('falls back to root prompt files when preset files are missing', () => {
1074
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
1075
+ const presetDir = path.join(promptsDir, 'test');
1076
+ fs.mkdirSync(presetDir, { recursive: true });
1077
+
1078
+ fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
1079
+ fs.writeFileSync(
1080
+ path.join(promptsDir, 'oracle_append.md'),
1081
+ 'root append prompt',
1082
+ );
1083
+
1084
+ const result = loadAgentPrompt('oracle', 'test');
1085
+ expect(result.prompt).toBe('root replacement');
1086
+ expect(result.appendPrompt).toBe('root append prompt');
1087
+ });
1088
+
1089
+ test('falls back independently between preset and root files', () => {
1090
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
1091
+ const presetDir = path.join(promptsDir, 'test');
1092
+ fs.mkdirSync(presetDir, { recursive: true });
1093
+
1094
+ fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
1095
+ fs.writeFileSync(
1096
+ path.join(promptsDir, 'oracle_append.md'),
1097
+ 'root append prompt',
1098
+ );
1099
+
1100
+ const result = loadAgentPrompt('oracle', 'test');
1101
+ expect(result.prompt).toBe('preset replacement');
1102
+ expect(result.appendPrompt).toBe('root append prompt');
1103
+ });
1104
+
1105
+ test('ignores unsafe preset names for prompt lookup', () => {
1106
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
1107
+ fs.mkdirSync(promptsDir, { recursive: true });
1108
+ fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
1109
+
1110
+ const result = loadAgentPrompt('oracle', '../test');
1111
+ expect(result.prompt).toBe('root replacement');
1112
+ expect(result.appendPrompt).toBeUndefined();
1113
+ });
1114
+
1115
+ test('falls back to root when preset prompt file read fails', () => {
1116
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
1117
+ const presetDir = path.join(promptsDir, 'test');
1118
+ fs.mkdirSync(presetDir, { recursive: true });
1119
+ const presetPromptPath = path.join(presetDir, 'oracle.md');
1120
+ fs.writeFileSync(presetPromptPath, 'preset replacement');
1121
+ fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
1122
+
1123
+ const consoleWarnSpy = spyOn(console, 'warn');
1124
+ const originalReadFileSync = fs.readFileSync;
1125
+ const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
1126
+ ...args: Parameters<typeof fs.readFileSync>
1127
+ ) => {
1128
+ const [p] = args;
1129
+ if (typeof p === 'string' && p === presetPromptPath) {
1130
+ throw new Error('Preset read error');
1131
+ }
1132
+ return originalReadFileSync(...args);
1133
+ }) as typeof fs.readFileSync);
1134
+
1135
+ try {
1136
+ const result = loadAgentPrompt('oracle', 'test');
1137
+ expect(result.prompt).toBe('root replacement');
1138
+ expect(consoleWarnSpy).toHaveBeenCalled();
1139
+ } finally {
1140
+ readSpy.mockRestore();
1141
+ }
1142
+ });
1143
+
1144
+ test('works with XDG_CONFIG_HOME environment variable', () => {
1145
+ const customConfigHome = path.join(tempDir, 'custom-xdg');
1146
+ process.env.XDG_CONFIG_HOME = customConfigHome;
1147
+
1148
+ const promptsDir = path.join(
1149
+ customConfigHome,
1150
+ 'opencode',
1151
+ 'opencode-dux',
1152
+ );
1153
+ fs.mkdirSync(promptsDir, { recursive: true });
1154
+ fs.writeFileSync(path.join(promptsDir, 'xdg-agent.md'), 'xdg prompt');
1155
+
1156
+ const result = loadAgentPrompt('xdg-agent');
1157
+ expect(result.prompt).toBe('xdg prompt');
1158
+ });
1159
+
1160
+ test('respects OPENCODE_CONFIG_DIR for prompt location', () => {
1161
+ const customDir = fs.mkdtempSync(
1162
+ path.join(os.tmpdir(), 'omc-prompt-config-'),
1163
+ );
1164
+ process.env.OPENCODE_CONFIG_DIR = customDir;
1165
+
1166
+ const promptsDir = path.join(customDir, 'opencode-dux');
1167
+ fs.mkdirSync(promptsDir, { recursive: true });
1168
+ fs.writeFileSync(
1169
+ path.join(promptsDir, 'oracle.md'),
1170
+ 'prompt from OPENCODE_CONFIG_DIR dir',
1171
+ );
1172
+
1173
+ const result = loadAgentPrompt('oracle');
1174
+ expect(result.prompt).toBe('prompt from OPENCODE_CONFIG_DIR dir');
1175
+
1176
+ fs.rmSync(customDir, { recursive: true, force: true });
1177
+ });
1178
+
1179
+ test('falls back to default prompt dir when OPENCODE_CONFIG_DIR has no prompt', () => {
1180
+ const customDir = fs.mkdtempSync(
1181
+ path.join(os.tmpdir(), 'omc-prompt-config-empty-'),
1182
+ );
1183
+ process.env.OPENCODE_CONFIG_DIR = customDir;
1184
+
1185
+ const promptsDir = path.join(tempDir, 'opencode', 'opencode-dux');
1186
+ fs.mkdirSync(promptsDir, { recursive: true });
1187
+ fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'fallback prompt');
1188
+
1189
+ const result = loadAgentPrompt('oracle');
1190
+ expect(result.prompt).toBe('fallback prompt');
1191
+
1192
+ fs.rmSync(customDir, { recursive: true, force: true });
1193
+ });
1194
+ });