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,269 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { stripJsonComments } from '../cli/config-io';
4
+ import { getConfigSearchDirs } from '../cli/paths';
5
+ import { type PluginConfig, PluginConfigSchema } from './schema';
6
+
7
+ const PROMPTS_DIR_NAME = 'opencode-dux';
8
+
9
+ /**
10
+ * Load and validate plugin configuration from a specific file path.
11
+ * Supports both .json and .jsonc formats (JSON with comments).
12
+ * Returns null if the file doesn't exist, is invalid, or cannot be read.
13
+ * Logs warnings for validation errors and unexpected read errors.
14
+ *
15
+ * @param configPath - Absolute path to the config file
16
+ * @returns Validated config object, or null if loading failed
17
+ */
18
+ function loadConfigFromPath(configPath: string): PluginConfig | null {
19
+ try {
20
+ const content = fs.readFileSync(configPath, 'utf-8');
21
+ // Use stripJsonComments to support JSONC format (comments and trailing commas)
22
+ const rawConfig = JSON.parse(stripJsonComments(content));
23
+ const result = PluginConfigSchema.safeParse(rawConfig);
24
+
25
+ if (!result.success) {
26
+ console.warn(`[opencode-dux] Invalid config at ${configPath}:`);
27
+ console.warn(result.error.format());
28
+ return null;
29
+ }
30
+
31
+ return result.data;
32
+ } catch (error) {
33
+ // File doesn't exist or isn't readable - this is expected and fine
34
+ if (
35
+ error instanceof Error &&
36
+ 'code' in error &&
37
+ (error as NodeJS.ErrnoException).code !== 'ENOENT'
38
+ ) {
39
+ console.warn(
40
+ `[opencode-dux] Error reading config from ${configPath}:`,
41
+ error.message,
42
+ );
43
+ }
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Find existing config file path, preferring .jsonc over .json.
50
+ * Checks for .jsonc first, then falls back to .json.
51
+ *
52
+ * @param basePath - Base path without extension (e.g., /path/to/opencode-dux)
53
+ * @returns Path to existing config file, or null if neither exists
54
+ */
55
+ function findConfigPath(basePath: string): string | null {
56
+ const jsoncPath = `${basePath}.jsonc`;
57
+ const jsonPath = `${basePath}.json`;
58
+
59
+ // Prefer .jsonc over .json
60
+ if (fs.existsSync(jsoncPath)) {
61
+ return jsoncPath;
62
+ }
63
+ if (fs.existsSync(jsonPath)) {
64
+ return jsonPath;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function findConfigPathInDirs(
70
+ configDirs: string[],
71
+ baseName: string,
72
+ ): string | null {
73
+ for (const configDir of configDirs) {
74
+ const configPath = findConfigPath(path.join(configDir, baseName));
75
+ if (configPath) {
76
+ return configPath;
77
+ }
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Recursively merge two objects, with override values taking precedence.
85
+ * For nested objects, merges recursively. For arrays and primitives, override replaces base.
86
+ *
87
+ * @param base - Base object to merge into
88
+ * @param override - Override object whose values take precedence
89
+ * @returns Merged object, or undefined if both inputs are undefined
90
+ */
91
+ export function deepMerge<T extends Record<string, unknown>>(
92
+ base?: T,
93
+ override?: T,
94
+ ): T | undefined {
95
+ if (!base) return override;
96
+ if (!override) return base;
97
+
98
+ const result = { ...base } as T;
99
+ for (const key of Object.keys(override) as (keyof T)[]) {
100
+ const baseVal = base[key];
101
+ const overrideVal = override[key];
102
+
103
+ if (
104
+ typeof baseVal === 'object' &&
105
+ baseVal !== null &&
106
+ typeof overrideVal === 'object' &&
107
+ overrideVal !== null &&
108
+ !Array.isArray(baseVal) &&
109
+ !Array.isArray(overrideVal)
110
+ ) {
111
+ result[key] = deepMerge(
112
+ baseVal as Record<string, unknown>,
113
+ overrideVal as Record<string, unknown>,
114
+ ) as T[keyof T];
115
+ } else {
116
+ result[key] = overrideVal;
117
+ }
118
+ }
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Load plugin configuration from user and project config files, merging them appropriately.
124
+ *
125
+ * Configuration is loaded from two locations:
126
+ * 1. User config: $OPENCODE_CONFIG_DIR/opencode-dux.jsonc or .json,
127
+ * or ~/.config/opencode/opencode-dux.jsonc or .json (or $XDG_CONFIG_HOME)
128
+ * 2. Project config: <directory>/.opencode/opencode-dux.jsonc or .json
129
+ *
130
+ * JSONC format is preferred over JSON (allows comments and trailing commas).
131
+ * Project config takes precedence over user config. Nested objects (agents) are
132
+ * deep-merged, while top-level arrays are replaced entirely by project config.
133
+ *
134
+ * @param directory - Project directory to search for .opencode config
135
+ * @returns Merged plugin configuration (empty object if no configs found)
136
+ */
137
+ export function loadPluginConfig(directory: string): PluginConfig {
138
+ const userConfigPath = findConfigPathInDirs(
139
+ getConfigSearchDirs(),
140
+ 'opencode-dux',
141
+ );
142
+
143
+ const projectConfigBasePath = path.join(
144
+ directory,
145
+ '.opencode',
146
+ 'opencode-dux',
147
+ );
148
+
149
+ // Find existing config files (preferring .jsonc over .json)
150
+ const projectConfigPath = findConfigPath(projectConfigBasePath);
151
+
152
+ let config: PluginConfig = userConfigPath
153
+ ? (loadConfigFromPath(userConfigPath) ?? {})
154
+ : {};
155
+
156
+ const projectConfig = projectConfigPath
157
+ ? loadConfigFromPath(projectConfigPath)
158
+ : null;
159
+ if (projectConfig) {
160
+ config = {
161
+ ...config,
162
+ ...projectConfig,
163
+ agents: deepMerge(config.agents, projectConfig.agents),
164
+ sessionManager: deepMerge(
165
+ config.sessionManager,
166
+ projectConfig.sessionManager,
167
+ ),
168
+ contextPressure: deepMerge(
169
+ config.contextPressure,
170
+ projectConfig.contextPressure,
171
+ ),
172
+ fallback: deepMerge(config.fallback, projectConfig.fallback),
173
+ };
174
+ }
175
+
176
+ // Override preset from environment variable if set
177
+ const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
178
+ if (envPreset) {
179
+ config.preset = envPreset;
180
+ }
181
+
182
+ // Resolve preset and merge with root agents
183
+ if (config.preset) {
184
+ const preset = config.presets?.[config.preset];
185
+ if (preset) {
186
+ // Merge preset agents with root agents (root overrides)
187
+ config.agents = deepMerge(preset, config.agents);
188
+ } else {
189
+ // Preset name specified but doesn't exist - warn user
190
+ const presetSource =
191
+ envPreset === config.preset ? 'environment variable' : 'config file';
192
+ const availablePresets = config.presets
193
+ ? Object.keys(config.presets).join(', ')
194
+ : 'none';
195
+ console.warn(
196
+ `[opencode-dux] Preset "${config.preset}" not found (from ${presetSource}). Available presets: ${availablePresets}`,
197
+ );
198
+ }
199
+ }
200
+
201
+ return config;
202
+ }
203
+
204
+ /**
205
+ * Load custom prompt for an agent from the prompts directory.
206
+ * Checks for {agent}.md (replaces default) and {agent}_append.md (appends to default).
207
+ * If preset is provided and safe for paths, it first checks {preset}/ subdirectory,
208
+ * then falls back to the root prompts directory.
209
+ *
210
+ * @param agentName - Name of the agent (e.g., "orchestrator", "explorer")
211
+ * @param preset - Optional preset name for preset-scoped prompt lookup
212
+ * @returns Object with prompt and/or appendPrompt if files exist
213
+ */
214
+ export function loadAgentPrompt(
215
+ agentName: string,
216
+ preset?: string,
217
+ ): {
218
+ prompt?: string;
219
+ appendPrompt?: string;
220
+ } {
221
+ const presetDirName =
222
+ preset && /^[a-zA-Z0-9_-]+$/.test(preset) ? preset : undefined;
223
+ const promptSearchDirs = getConfigSearchDirs().flatMap((configDir) => {
224
+ const promptsDir = path.join(configDir, PROMPTS_DIR_NAME);
225
+ return presetDirName
226
+ ? [path.join(promptsDir, presetDirName), promptsDir]
227
+ : [promptsDir];
228
+ });
229
+ const result: { prompt?: string; appendPrompt?: string } = {};
230
+
231
+ const readFirstPrompt = (
232
+ fileName: string,
233
+ errorPrefix: string,
234
+ ): string | undefined => {
235
+ for (const dir of promptSearchDirs) {
236
+ const promptPath = path.join(dir, fileName);
237
+ if (!fs.existsSync(promptPath)) {
238
+ continue;
239
+ }
240
+
241
+ try {
242
+ return fs.readFileSync(promptPath, 'utf-8');
243
+ } catch (error) {
244
+ console.warn(
245
+ `[opencode-dux] ${errorPrefix} ${promptPath}:`,
246
+ error instanceof Error ? error.message : String(error),
247
+ );
248
+ }
249
+ }
250
+
251
+ return undefined;
252
+ };
253
+
254
+ // Check for replacement prompt
255
+ result.prompt = readFirstPrompt(
256
+ `${agentName}.md`,
257
+ 'Error reading prompt file',
258
+ );
259
+
260
+ // Check for append prompt
261
+ result.appendPrompt = readFirstPrompt(
262
+ `${agentName}_append.md`,
263
+ 'Error reading append prompt file',
264
+ );
265
+
266
+ return result;
267
+ }
268
+
269
+
@@ -0,0 +1,176 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ModelEntry } from '../config/schema';
3
+
4
+ /**
5
+ * Test the model array resolution logic that runs in the config hook.
6
+ * This logic determines which model to use from an effective model array.
7
+ *
8
+ * The resolver always picks the first model in the effective array,
9
+ * regardless of provider configuration. This is correct because:
10
+ * - Not all providers require entries in opencodeConfig.provider - some are
11
+ * loaded automatically by opencode (e.g. github-copilot, openrouter).
12
+ * - We cannot distinguish "auto-loaded provider" from "provider not configured"
13
+ * without calling the API, which isn't available at config-hook time.
14
+ * - Runtime failover (rate-limit handling) is handled separately by
15
+ * ForegroundFallbackManager.
16
+ */
17
+
18
+ describe('model array resolution', () => {
19
+ /**
20
+ * Simulates the resolution logic from src/index.ts.
21
+ * Always returns the first model in the array.
22
+ */
23
+ function resolveModelFromArray(
24
+ modelArray: Array<{ id: string; variant?: string }>,
25
+ ): { model: string; variant?: string } | null {
26
+ if (!modelArray || modelArray.length === 0) return null;
27
+
28
+ const chosen = modelArray[0];
29
+ return {
30
+ model: chosen.id,
31
+ variant: chosen.variant,
32
+ };
33
+ }
34
+
35
+ test('uses first model when no provider config exists', () => {
36
+ const modelArray: ModelEntry[] = [
37
+ { id: 'opencode/big-pickle', variant: 'high' },
38
+ { id: 'iflowcn/qwen3-235b-a22b-thinking-2507', variant: 'high' },
39
+ ];
40
+
41
+ const result = resolveModelFromArray(modelArray);
42
+
43
+ expect(result?.model).toBe('opencode/big-pickle');
44
+ expect(result?.variant).toBe('high');
45
+ });
46
+
47
+ test('uses first model even when other providers are configured', () => {
48
+ const modelArray: ModelEntry[] = [
49
+ { id: 'github-copilot/claude-opus-4.6', variant: 'high' },
50
+ { id: 'zai-coding-plan/glm-5' },
51
+ ];
52
+
53
+ const result = resolveModelFromArray(modelArray);
54
+
55
+ // Auto-loaded provider should not be skipped in favor of configured one
56
+ expect(result?.model).toBe('github-copilot/claude-opus-4.6');
57
+ expect(result?.variant).toBe('high');
58
+ });
59
+
60
+ test('returns null for empty model array', () => {
61
+ const modelArray: ModelEntry[] = [];
62
+
63
+ const result = resolveModelFromArray(modelArray);
64
+
65
+ expect(result).toBeNull();
66
+ });
67
+ });
68
+
69
+ /**
70
+ * Tests for the fallback.chains merging logic that runs in the config hook.
71
+ * Mirrors the effectiveArrays construction in src/index.ts.
72
+ */
73
+ describe('fallback.chains merging for foreground agents', () => {
74
+ /**
75
+ * Simulates the effectiveArrays construction + resolution from src/index.ts.
76
+ * Returns the resolved model string or null.
77
+ */
78
+ function resolveWithChains(opts: {
79
+ modelArray?: Array<{ id: string; variant?: string }>;
80
+ currentModel?: string;
81
+ chainModels?: string[];
82
+ fallbackEnabled?: boolean;
83
+ }): string | null {
84
+ const {
85
+ modelArray,
86
+ currentModel,
87
+ chainModels,
88
+ fallbackEnabled = true,
89
+ } = opts;
90
+
91
+ // Build effectiveArrays (mirrors index.ts logic)
92
+ const effectiveArray: Array<{ id: string; variant?: string }> = modelArray
93
+ ? [...modelArray]
94
+ : [];
95
+
96
+ if (fallbackEnabled && chainModels && chainModels.length > 0) {
97
+ if (effectiveArray.length === 0 && currentModel) {
98
+ effectiveArray.push({ id: currentModel });
99
+ }
100
+ const seen = new Set(effectiveArray.map((m) => m.id));
101
+ for (const chainModel of chainModels) {
102
+ if (!seen.has(chainModel)) {
103
+ seen.add(chainModel);
104
+ effectiveArray.push({ id: chainModel });
105
+ }
106
+ }
107
+ }
108
+
109
+ if (effectiveArray.length === 0) return null;
110
+
111
+ // Resolution: always use first model in effective array
112
+ return effectiveArray[0].id;
113
+ }
114
+
115
+ test('primary model wins regardless of provider config', () => {
116
+ const result = resolveWithChains({
117
+ currentModel: 'anthropic/claude-opus-4-5',
118
+ chainModels: ['openai/gpt-4o'],
119
+ });
120
+ expect(result).toBe('anthropic/claude-opus-4-5');
121
+ });
122
+
123
+ test('chain is ignored when fallback disabled', () => {
124
+ const result = resolveWithChains({
125
+ currentModel: 'anthropic/claude-opus-4-5',
126
+ chainModels: ['openai/gpt-4o'],
127
+ fallbackEnabled: false,
128
+ });
129
+ // chain not applied; no effectiveArray entry → falls through to null (no _modelArray either)
130
+ expect(result).toBeNull();
131
+ });
132
+
133
+ test('_modelArray entries take precedence and chain appends after', () => {
134
+ const result = resolveWithChains({
135
+ modelArray: [
136
+ { id: 'anthropic/claude-opus-4-5' },
137
+ { id: 'anthropic/claude-sonnet-4-5' },
138
+ ],
139
+ chainModels: ['openai/gpt-4o'],
140
+ });
141
+ // First entry in _modelArray wins; chain only used for runtime failover
142
+ expect(result).toBe('anthropic/claude-opus-4-5');
143
+ });
144
+
145
+ test('duplicate model ids across array and chain are deduplicated', () => {
146
+ const result = resolveWithChains({
147
+ modelArray: [
148
+ { id: 'anthropic/claude-opus-4-5' },
149
+ { id: 'openai/gpt-4o' },
150
+ ],
151
+ chainModels: ['openai/gpt-4o', 'google/gemini-pro'],
152
+ });
153
+ expect(result).toBe('anthropic/claude-opus-4-5');
154
+ });
155
+
156
+ test('no currentModel and no _modelArray with chain still resolves', () => {
157
+ const result = resolveWithChains({
158
+ chainModels: ['openai/gpt-4o', 'anthropic/claude-sonnet-4-5'],
159
+ });
160
+ expect(result).toBe('openai/gpt-4o');
161
+ });
162
+
163
+ test('built-in provider not skipped when other providers are configured', () => {
164
+ // Regression test: github-copilot is auto-loaded by opencode and doesn't
165
+ // need an entry in opencodeConfig.provider. The resolver must not skip
166
+ // it in favor of a configured provider later in the chain.
167
+ const result = resolveWithChains({
168
+ currentModel: 'github-copilot/claude-opus-4.6',
169
+ chainModels: [
170
+ 'github-copilot/gemini-3.1-pro-preview',
171
+ 'zai-coding-plan/glm-5',
172
+ ],
173
+ });
174
+ expect(result).toBe('github-copilot/claude-opus-4.6');
175
+ });
176
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ getActiveRuntimePreset,
4
+ getPreviousRuntimePreset,
5
+ rollbackRuntimePreset,
6
+ setActiveRuntimePreset,
7
+ setActiveRuntimePresetWithPrevious,
8
+ } from './runtime-preset';
9
+
10
+ describe('runtime-preset', () => {
11
+ // Cleanup after each test to avoid state leakage
12
+ test('getActiveRuntimePreset returns null initially', () => {
13
+ setActiveRuntimePreset(null);
14
+ expect(getActiveRuntimePreset()).toBeNull();
15
+ setActiveRuntimePreset(null);
16
+ });
17
+
18
+ test('setActiveRuntimePreset sets the active preset', () => {
19
+ setActiveRuntimePreset(null);
20
+ setActiveRuntimePreset('foo');
21
+ expect(getActiveRuntimePreset()).toBe('foo');
22
+ setActiveRuntimePreset(null);
23
+ });
24
+
25
+ test('setActiveRuntimePresetWithPrevious sets active and previous', () => {
26
+ setActiveRuntimePreset(null);
27
+ setActiveRuntimePreset('old');
28
+ setActiveRuntimePresetWithPrevious('new');
29
+ expect(getActiveRuntimePreset()).toBe('new');
30
+ expect(getPreviousRuntimePreset()).toBe('old');
31
+ setActiveRuntimePreset(null);
32
+ });
33
+
34
+ test('setActiveRuntimePresetWithPrevious with null sets previous to old', () => {
35
+ setActiveRuntimePreset(null);
36
+ setActiveRuntimePreset('old');
37
+ setActiveRuntimePresetWithPrevious(null);
38
+ expect(getActiveRuntimePreset()).toBeNull();
39
+ expect(getPreviousRuntimePreset()).toBe('old');
40
+ setActiveRuntimePreset(null);
41
+ });
42
+
43
+ test('rollbackRuntimePreset restores active and clears previous', () => {
44
+ setActiveRuntimePreset(null);
45
+ setActiveRuntimePreset('old');
46
+ setActiveRuntimePresetWithPrevious('new');
47
+ rollbackRuntimePreset('old');
48
+ expect(getActiveRuntimePreset()).toBe('old');
49
+ expect(getPreviousRuntimePreset()).toBeNull();
50
+ setActiveRuntimePreset(null);
51
+ });
52
+
53
+ test('rollbackRuntimePreset with null clears active and previous', () => {
54
+ setActiveRuntimePreset(null);
55
+ setActiveRuntimePresetWithPrevious('new');
56
+ rollbackRuntimePreset(null);
57
+ expect(getActiveRuntimePreset()).toBeNull();
58
+ expect(getPreviousRuntimePreset()).toBeNull();
59
+ setActiveRuntimePreset(null);
60
+ });
61
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Module-level runtime preset state.
3
+ *
4
+ * Survives plugin re-inits triggered by client.config.update() →
5
+ * Instance.dispose(). The plugin function re-runs but this module-level
6
+ * variable persists within the same Node.js process.
7
+ */
8
+
9
+ let activeRuntimePreset: string | null = null;
10
+
11
+ export function setActiveRuntimePreset(name: string | null): void {
12
+ activeRuntimePreset = name;
13
+ }
14
+
15
+ export function getActiveRuntimePreset(): string | null {
16
+ return activeRuntimePreset;
17
+ }
18
+
19
+ /**
20
+ * Returns the name of the previously active runtime preset (before the
21
+ * current one), used to compute reset diffs when switching presets.
22
+ */
23
+ let previousRuntimePreset: string | null = null;
24
+
25
+ export function getPreviousRuntimePreset(): string | null {
26
+ return previousRuntimePreset;
27
+ }
28
+
29
+ export function setActiveRuntimePresetWithPrevious(name: string | null): void {
30
+ previousRuntimePreset = activeRuntimePreset;
31
+ activeRuntimePreset = name;
32
+ }
33
+
34
+ export function rollbackRuntimePreset(previous: string | null): void {
35
+ activeRuntimePreset = previous;
36
+ previousRuntimePreset = null;
37
+ }