loader.test.ts 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656
  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 type { ConfigLoadWarning } from './loader';
  6. import { loadAgentPrompt, loadPluginConfig } from './loader';
  7. // Test deepMerge indirectly through loadPluginConfig behavior
  8. // since deepMerge is not exported
  9. describe('loadPluginConfig', () => {
  10. let tempDir: string;
  11. let userConfigDir: string;
  12. let originalEnv: typeof process.env;
  13. beforeEach(() => {
  14. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loader-test-'));
  15. userConfigDir = path.join(tempDir, 'user-config');
  16. originalEnv = { ...process.env };
  17. // Isolate from real user config
  18. delete process.env.OPENCODE_CONFIG_DIR;
  19. process.env.XDG_CONFIG_HOME = userConfigDir;
  20. });
  21. afterEach(() => {
  22. fs.rmSync(tempDir, { recursive: true, force: true });
  23. process.env = originalEnv;
  24. });
  25. test('returns empty config when no config files exist', () => {
  26. const projectDir = path.join(tempDir, 'project');
  27. fs.mkdirSync(projectDir, { recursive: true });
  28. const config = loadPluginConfig(projectDir);
  29. expect(config).toEqual({});
  30. });
  31. test('loads project config from .opencode directory', () => {
  32. const projectDir = path.join(tempDir, 'project');
  33. const projectConfigDir = path.join(projectDir, '.opencode');
  34. fs.mkdirSync(projectConfigDir, { recursive: true });
  35. fs.writeFileSync(
  36. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  37. JSON.stringify({
  38. agents: {
  39. oracle: { model: 'test/model' },
  40. },
  41. }),
  42. );
  43. const config = loadPluginConfig(projectDir);
  44. expect(config.agents?.oracle?.model).toBe('test/model');
  45. });
  46. test('loads scoringEngineVersion flag when configured', () => {
  47. const projectDir = path.join(tempDir, 'project');
  48. const projectConfigDir = path.join(projectDir, '.opencode');
  49. fs.mkdirSync(projectConfigDir, { recursive: true });
  50. fs.writeFileSync(
  51. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  52. JSON.stringify({
  53. scoringEngineVersion: 'v2-shadow',
  54. }),
  55. );
  56. const config = loadPluginConfig(projectDir);
  57. expect(config.scoringEngineVersion).toBe('v2-shadow');
  58. });
  59. test('loads balanceProviderUsage flag when configured', () => {
  60. const projectDir = path.join(tempDir, 'project');
  61. const projectConfigDir = path.join(projectDir, '.opencode');
  62. fs.mkdirSync(projectConfigDir, { recursive: true });
  63. fs.writeFileSync(
  64. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  65. JSON.stringify({
  66. balanceProviderUsage: true,
  67. }),
  68. );
  69. const config = loadPluginConfig(projectDir);
  70. expect(config.balanceProviderUsage).toBe(true);
  71. });
  72. test('loads autoUpdate flag when configured', () => {
  73. const projectDir = path.join(tempDir, 'project');
  74. const projectConfigDir = path.join(projectDir, '.opencode');
  75. fs.mkdirSync(projectConfigDir, { recursive: true });
  76. fs.writeFileSync(
  77. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  78. JSON.stringify({
  79. autoUpdate: false,
  80. }),
  81. );
  82. const config = loadPluginConfig(projectDir);
  83. expect(config.autoUpdate).toBe(false);
  84. });
  85. test('loads manual plan structure when configured', () => {
  86. const projectDir = path.join(tempDir, 'project');
  87. const projectConfigDir = path.join(projectDir, '.opencode');
  88. fs.mkdirSync(projectConfigDir, { recursive: true });
  89. fs.writeFileSync(
  90. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  91. JSON.stringify({
  92. manualPlan: {
  93. orchestrator: {
  94. primary: 'openai/gpt-5.5',
  95. fallback1: 'anthropic/claude-opus-4-6',
  96. fallback2: 'chutes/kimi-k2.5',
  97. fallback3: 'opencode/gpt-5-nano',
  98. },
  99. oracle: {
  100. primary: 'openai/gpt-5.5',
  101. fallback1: 'anthropic/claude-opus-4-6',
  102. fallback2: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
  103. fallback3: 'opencode/gpt-5-nano',
  104. },
  105. designer: {
  106. primary: 'openai/gpt-5.5',
  107. fallback1: 'anthropic/claude-opus-4-6',
  108. fallback2: 'chutes/kimi-k2.5',
  109. fallback3: 'opencode/gpt-5-nano',
  110. },
  111. explorer: {
  112. primary: 'openai/gpt-5.5',
  113. fallback1: 'anthropic/claude-opus-4-6',
  114. fallback2: 'chutes/kimi-k2.5',
  115. fallback3: 'opencode/gpt-5-nano',
  116. },
  117. librarian: {
  118. primary: 'openai/gpt-5.5',
  119. fallback1: 'anthropic/claude-opus-4-6',
  120. fallback2: 'chutes/kimi-k2.5',
  121. fallback3: 'opencode/gpt-5-nano',
  122. },
  123. fixer: {
  124. primary: 'openai/gpt-5.5',
  125. fallback1: 'anthropic/claude-opus-4-6',
  126. fallback2: 'chutes/kimi-k2.5',
  127. fallback3: 'opencode/gpt-5-nano',
  128. },
  129. },
  130. }),
  131. );
  132. const config = loadPluginConfig(projectDir);
  133. expect(config.manualPlan?.oracle?.fallback2).toBe(
  134. 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
  135. );
  136. });
  137. test('ignores invalid config (schema violation or malformed JSON)', () => {
  138. const projectDir = path.join(tempDir, 'project');
  139. const projectConfigDir = path.join(projectDir, '.opencode');
  140. fs.mkdirSync(projectConfigDir, { recursive: true });
  141. // Test 1: Invalid temperature (out of range)
  142. fs.writeFileSync(
  143. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  144. JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
  145. );
  146. expect(loadPluginConfig(projectDir)).toEqual({});
  147. // Test 2: Malformed JSON
  148. fs.writeFileSync(
  149. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  150. '{ invalid json }',
  151. );
  152. expect(loadPluginConfig(projectDir)).toEqual({});
  153. });
  154. test('rejects custom-only prompt fields on built-in agents in config files', () => {
  155. const projectDir = path.join(tempDir, 'project');
  156. const projectConfigDir = path.join(projectDir, '.opencode');
  157. fs.mkdirSync(projectConfigDir, { recursive: true });
  158. fs.writeFileSync(
  159. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  160. JSON.stringify({
  161. agents: {
  162. oracle: {
  163. model: 'openai/gpt-5.5',
  164. prompt: 'This should be rejected for built-in agents.',
  165. },
  166. },
  167. }),
  168. );
  169. expect(loadPluginConfig(projectDir)).toEqual({});
  170. });
  171. test('respects OPENCODE_CONFIG_DIR for user config location', () => {
  172. const customDir = fs.mkdtempSync(
  173. path.join(os.tmpdir(), 'omc-opencode-config-'),
  174. );
  175. process.env.OPENCODE_CONFIG_DIR = customDir;
  176. // Write plugin config in the custom directory
  177. fs.writeFileSync(
  178. path.join(customDir, 'oh-my-opencode-slim.json'),
  179. JSON.stringify({
  180. agents: { oracle: { model: 'custom/model-from-opencode-config-dir' } },
  181. }),
  182. );
  183. const projectDir = path.join(tempDir, 'project');
  184. fs.mkdirSync(projectDir, { recursive: true });
  185. const config = loadPluginConfig(projectDir);
  186. expect(config.agents?.oracle?.model).toBe(
  187. 'custom/model-from-opencode-config-dir',
  188. );
  189. fs.rmSync(customDir, { recursive: true, force: true });
  190. });
  191. test('falls back to default user config dir when OPENCODE_CONFIG_DIR has no config', () => {
  192. const customDir = fs.mkdtempSync(
  193. path.join(os.tmpdir(), 'omc-opencode-config-empty-'),
  194. );
  195. process.env.OPENCODE_CONFIG_DIR = customDir;
  196. const defaultConfigDir = path.join(userConfigDir, 'opencode');
  197. fs.mkdirSync(defaultConfigDir, { recursive: true });
  198. fs.writeFileSync(
  199. path.join(defaultConfigDir, 'oh-my-opencode-slim.json'),
  200. JSON.stringify({
  201. agents: { oracle: { model: 'fallback/default-config' } },
  202. }),
  203. );
  204. const projectDir = path.join(tempDir, 'project');
  205. fs.mkdirSync(projectDir, { recursive: true });
  206. const config = loadPluginConfig(projectDir);
  207. expect(config.agents?.oracle?.model).toBe('fallback/default-config');
  208. fs.rmSync(customDir, { recursive: true, force: true });
  209. });
  210. });
  211. describe('onWarning callback', () => {
  212. let tempDir: string;
  213. let originalEnv: typeof process.env;
  214. beforeEach(() => {
  215. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onwarning-test-'));
  216. originalEnv = { ...process.env };
  217. delete process.env.OPENCODE_CONFIG_DIR;
  218. process.env.XDG_CONFIG_HOME = tempDir;
  219. });
  220. afterEach(() => {
  221. fs.rmSync(tempDir, { recursive: true, force: true });
  222. process.env = originalEnv;
  223. });
  224. test('invalid schema calls onWarning with invalid-schema', () => {
  225. const projectDir = path.join(tempDir, 'project');
  226. const projectConfigDir = path.join(projectDir, '.opencode');
  227. fs.mkdirSync(projectConfigDir, { recursive: true });
  228. fs.writeFileSync(
  229. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  230. JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
  231. );
  232. const warnings: ConfigLoadWarning[] = [];
  233. const config = loadPluginConfig(projectDir, {
  234. onWarning: (warning) => warnings.push(warning),
  235. });
  236. expect(warnings).toHaveLength(1);
  237. expect(warnings[0]?.kind).toBe('invalid-schema');
  238. expect(warnings[0]?.path).toBe(
  239. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  240. );
  241. expect(warnings[0]?.message).toBe('Config does not match schema');
  242. expect(config).toEqual({});
  243. });
  244. test('invalid JSON calls onWarning with invalid-json', () => {
  245. const projectDir = path.join(tempDir, 'project');
  246. const projectConfigDir = path.join(projectDir, '.opencode');
  247. fs.mkdirSync(projectConfigDir, { recursive: true });
  248. fs.writeFileSync(
  249. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  250. '{ invalid json }',
  251. );
  252. const warnings: ConfigLoadWarning[] = [];
  253. const config = loadPluginConfig(projectDir, {
  254. onWarning: (warning) => warnings.push(warning),
  255. });
  256. expect(warnings).toHaveLength(1);
  257. expect(warnings[0]?.kind).toBe('invalid-json');
  258. expect(warnings[0]?.path).toBe(
  259. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  260. );
  261. expect(config).toEqual({});
  262. });
  263. test('silent option suppresses console warnings', () => {
  264. const projectDir = path.join(tempDir, 'project');
  265. const projectConfigDir = path.join(projectDir, '.opencode');
  266. fs.mkdirSync(projectConfigDir, { recursive: true });
  267. fs.writeFileSync(
  268. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  269. '{ invalid json }',
  270. );
  271. const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
  272. try {
  273. const warnings: ConfigLoadWarning[] = [];
  274. const config = loadPluginConfig(projectDir, {
  275. silent: true,
  276. onWarning: (warning) => warnings.push(warning),
  277. });
  278. expect(warnings).toHaveLength(1);
  279. expect(config).toEqual({});
  280. expect(warnSpy).not.toHaveBeenCalled();
  281. } finally {
  282. warnSpy.mockRestore();
  283. }
  284. });
  285. test('read error calls onWarning with read-error', () => {
  286. const projectDir = path.join(tempDir, 'project');
  287. const projectConfigDir = path.join(projectDir, '.opencode');
  288. const configPath = path.join(projectConfigDir, 'oh-my-opencode-slim.json');
  289. fs.mkdirSync(projectConfigDir, { recursive: true });
  290. fs.writeFileSync(configPath, JSON.stringify({}));
  291. const originalReadFileSync = fs.readFileSync;
  292. const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
  293. ...args: Parameters<typeof fs.readFileSync>
  294. ) => {
  295. const [filePath] = args;
  296. if (filePath === configPath) {
  297. const error = new Error('Permission denied') as NodeJS.ErrnoException;
  298. error.code = 'EACCES';
  299. throw error;
  300. }
  301. return originalReadFileSync(...args);
  302. }) as typeof fs.readFileSync);
  303. try {
  304. const warnings: ConfigLoadWarning[] = [];
  305. const config = loadPluginConfig(projectDir, {
  306. onWarning: (warning) => warnings.push(warning),
  307. });
  308. expect(warnings).toHaveLength(1);
  309. expect(warnings[0]?.kind).toBe('read-error');
  310. expect(warnings[0]?.path).toBe(configPath);
  311. expect(warnings[0]?.message).toBe('Permission denied');
  312. expect(config).toEqual({});
  313. } finally {
  314. readSpy.mockRestore();
  315. }
  316. });
  317. test('missing preset calls onWarning with missing-preset', () => {
  318. const projectDir = path.join(tempDir, 'project');
  319. const projectConfigDir = path.join(projectDir, '.opencode');
  320. fs.mkdirSync(projectConfigDir, { recursive: true });
  321. fs.writeFileSync(
  322. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  323. JSON.stringify({
  324. preset: 'nonexistent',
  325. presets: { other: { oracle: { model: 'other' } } },
  326. agents: { oracle: { model: 'root' } },
  327. }),
  328. );
  329. const warnings: ConfigLoadWarning[] = [];
  330. const config = loadPluginConfig(projectDir, {
  331. onWarning: (warning) => warnings.push(warning),
  332. });
  333. expect(warnings).toHaveLength(1);
  334. expect(warnings[0]?.kind).toBe('missing-preset');
  335. expect(warnings[0]?.message).toContain('Preset "nonexistent" not found');
  336. expect(config.agents?.oracle?.model).toBe('root');
  337. });
  338. test('silent: true on missing preset still calls onWarning but not console.warn', () => {
  339. const projectDir = path.join(tempDir, 'project');
  340. const projectConfigDir = path.join(projectDir, '.opencode');
  341. fs.mkdirSync(projectConfigDir, { recursive: true });
  342. fs.writeFileSync(
  343. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  344. JSON.stringify({
  345. preset: 'nonexistent',
  346. presets: { other: { oracle: { model: 'other' } } },
  347. agents: { oracle: { model: 'root' } },
  348. }),
  349. );
  350. const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
  351. try {
  352. const warnings: ConfigLoadWarning[] = [];
  353. const config = loadPluginConfig(projectDir, {
  354. silent: true,
  355. onWarning: (warning) => warnings.push(warning),
  356. });
  357. expect(warnings).toHaveLength(1);
  358. expect(warnings[0]?.kind).toBe('missing-preset');
  359. expect(config.agents?.oracle?.model).toBe('root');
  360. expect(warnSpy).not.toHaveBeenCalled();
  361. } finally {
  362. warnSpy.mockRestore();
  363. }
  364. });
  365. test('valid config does not call onWarning', () => {
  366. const projectDir = path.join(tempDir, 'project');
  367. const projectConfigDir = path.join(projectDir, '.opencode');
  368. fs.mkdirSync(projectConfigDir, { recursive: true });
  369. fs.writeFileSync(
  370. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  371. JSON.stringify({ agents: { oracle: { model: 'valid/model' } } }),
  372. );
  373. const warnings: ConfigLoadWarning[] = [];
  374. const config = loadPluginConfig(projectDir, {
  375. onWarning: (warning) => warnings.push(warning),
  376. });
  377. expect(warnings).toHaveLength(0);
  378. expect(config.agents?.oracle?.model).toBe('valid/model');
  379. });
  380. test('no options object does not break loadPluginConfig', () => {
  381. const projectDir = path.join(tempDir, 'project');
  382. const projectConfigDir = path.join(projectDir, '.opencode');
  383. fs.mkdirSync(projectConfigDir, { recursive: true });
  384. fs.writeFileSync(
  385. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  386. JSON.stringify({ agents: { oracle: { model: 'model' } } }),
  387. );
  388. const config = loadPluginConfig(projectDir);
  389. expect(config.agents?.oracle?.model).toBe('model');
  390. });
  391. });
  392. describe('deepMerge behavior', () => {
  393. let tempDir: string;
  394. let userConfigDir: string;
  395. let originalEnv: typeof process.env;
  396. beforeEach(() => {
  397. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-test-'));
  398. userConfigDir = path.join(tempDir, 'user-config');
  399. originalEnv = { ...process.env };
  400. // Set XDG_CONFIG_HOME to control user config location
  401. delete process.env.OPENCODE_CONFIG_DIR;
  402. process.env.XDG_CONFIG_HOME = userConfigDir;
  403. });
  404. afterEach(() => {
  405. fs.rmSync(tempDir, { recursive: true, force: true });
  406. process.env = originalEnv;
  407. });
  408. test('merges nested agent configs from user and project', () => {
  409. // Create user config
  410. const userOpencodeDir = path.join(userConfigDir, 'opencode');
  411. fs.mkdirSync(userOpencodeDir, { recursive: true });
  412. fs.writeFileSync(
  413. path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
  414. JSON.stringify({
  415. agents: {
  416. oracle: { model: 'user/oracle-model', temperature: 0.5 },
  417. explorer: { model: 'user/explorer-model' },
  418. },
  419. }),
  420. );
  421. // Create project config (should override/merge with user)
  422. const projectDir = path.join(tempDir, 'project');
  423. const projectConfigDir = path.join(projectDir, '.opencode');
  424. fs.mkdirSync(projectConfigDir, { recursive: true });
  425. fs.writeFileSync(
  426. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  427. JSON.stringify({
  428. agents: {
  429. oracle: { temperature: 0.8 }, // Override temperature only
  430. designer: { model: 'project/designer-model' }, // Add new agent
  431. },
  432. }),
  433. );
  434. const config = loadPluginConfig(projectDir);
  435. // oracle: model from user, temperature from project
  436. expect(config.agents?.oracle?.model).toBe('user/oracle-model');
  437. expect(config.agents?.oracle?.temperature).toBe(0.8);
  438. // explorer: from user only
  439. expect(config.agents?.explorer?.model).toBe('user/explorer-model');
  440. // designer: from project only
  441. expect(config.agents?.designer?.model).toBe('project/designer-model');
  442. });
  443. test('merges nested tmux configs', () => {
  444. const userOpencodeDir = path.join(userConfigDir, 'opencode');
  445. fs.mkdirSync(userOpencodeDir, { recursive: true });
  446. fs.writeFileSync(
  447. path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
  448. JSON.stringify({
  449. tmux: {
  450. enabled: true,
  451. layout: 'main-vertical',
  452. main_pane_size: 60,
  453. },
  454. }),
  455. );
  456. const projectDir = path.join(tempDir, 'project');
  457. const projectConfigDir = path.join(projectDir, '.opencode');
  458. fs.mkdirSync(projectConfigDir, { recursive: true });
  459. fs.writeFileSync(
  460. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  461. JSON.stringify({
  462. tmux: {
  463. enabled: false, // Override enabled
  464. layout: 'tiled', // Override layout
  465. },
  466. }),
  467. );
  468. const config = loadPluginConfig(projectDir);
  469. expect(config.tmux?.enabled).toBe(false); // From project (override)
  470. expect(config.tmux?.layout).toBe('tiled'); // From project
  471. expect(config.tmux?.main_pane_size).toBe(60); // From user (preserved)
  472. });
  473. test("preserves user tmux.enabled when project doesn't specify", () => {
  474. const userOpencodeDir = path.join(userConfigDir, 'opencode');
  475. fs.mkdirSync(userOpencodeDir, { recursive: true });
  476. fs.writeFileSync(
  477. path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
  478. JSON.stringify({
  479. tmux: {
  480. enabled: true,
  481. layout: 'main-vertical',
  482. },
  483. }),
  484. );
  485. const projectDir = path.join(tempDir, 'project');
  486. const projectConfigDir = path.join(projectDir, '.opencode');
  487. fs.mkdirSync(projectConfigDir, { recursive: true });
  488. fs.writeFileSync(
  489. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  490. JSON.stringify({
  491. agents: { oracle: { model: 'test' } }, // No tmux override
  492. }),
  493. );
  494. const config = loadPluginConfig(projectDir);
  495. expect(config.tmux?.enabled).toBe(true); // Preserved from user
  496. expect(config.tmux?.layout).toBe('main-vertical'); // Preserved from user
  497. });
  498. test('project config overrides top-level arrays', () => {
  499. const userOpencodeDir = path.join(userConfigDir, 'opencode');
  500. fs.mkdirSync(userOpencodeDir, { recursive: true });
  501. fs.writeFileSync(
  502. path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
  503. JSON.stringify({
  504. disabled_mcps: ['websearch'],
  505. }),
  506. );
  507. const projectDir = path.join(tempDir, 'project');
  508. const projectConfigDir = path.join(projectDir, '.opencode');
  509. fs.mkdirSync(projectConfigDir, { recursive: true });
  510. fs.writeFileSync(
  511. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  512. JSON.stringify({
  513. disabled_mcps: ['context7'],
  514. }),
  515. );
  516. const config = loadPluginConfig(projectDir);
  517. // disabled_mcps should be from project (overwrites, not merges)
  518. expect(config.disabled_mcps).toEqual(['context7']);
  519. });
  520. test('handles missing user config gracefully', () => {
  521. // Don't create user config, only project
  522. const projectDir = path.join(tempDir, 'project');
  523. const projectConfigDir = path.join(projectDir, '.opencode');
  524. fs.mkdirSync(projectConfigDir, { recursive: true });
  525. fs.writeFileSync(
  526. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  527. JSON.stringify({
  528. agents: {
  529. oracle: { model: 'project/model' },
  530. },
  531. }),
  532. );
  533. const config = loadPluginConfig(projectDir);
  534. expect(config.agents?.oracle?.model).toBe('project/model');
  535. });
  536. test('handles missing project config gracefully', () => {
  537. const userOpencodeDir = path.join(userConfigDir, 'opencode');
  538. fs.mkdirSync(userOpencodeDir, { recursive: true });
  539. fs.writeFileSync(
  540. path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
  541. JSON.stringify({
  542. agents: {
  543. oracle: { model: 'user/model' },
  544. },
  545. }),
  546. );
  547. // No project config
  548. const projectDir = path.join(tempDir, 'project');
  549. fs.mkdirSync(projectDir, { recursive: true });
  550. const config = loadPluginConfig(projectDir);
  551. expect(config.agents?.oracle?.model).toBe('user/model');
  552. });
  553. test('merges fallback timeout and chains from user and project', () => {
  554. const userOpencodeDir = path.join(userConfigDir, 'opencode');
  555. fs.mkdirSync(userOpencodeDir, { recursive: true });
  556. fs.writeFileSync(
  557. path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
  558. JSON.stringify({
  559. fallback: {
  560. timeoutMs: 15000,
  561. chains: {
  562. oracle: ['openai/gpt-5.5', 'opencode/glm-4.7-free'],
  563. },
  564. },
  565. }),
  566. );
  567. const projectDir = path.join(tempDir, 'project');
  568. const projectConfigDir = path.join(projectDir, '.opencode');
  569. fs.mkdirSync(projectConfigDir, { recursive: true });
  570. fs.writeFileSync(
  571. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  572. JSON.stringify({
  573. fallback: {
  574. chains: {
  575. explorer: ['google/antigravity-gemini-3-flash'],
  576. },
  577. },
  578. }),
  579. );
  580. const config = loadPluginConfig(projectDir);
  581. expect(config.fallback?.timeoutMs).toBe(15000);
  582. expect(config.fallback?.chains.oracle).toEqual([
  583. 'openai/gpt-5.5',
  584. 'opencode/glm-4.7-free',
  585. ]);
  586. expect(config.fallback?.chains.explorer).toEqual([
  587. 'google/antigravity-gemini-3-flash',
  588. ]);
  589. });
  590. test('preserves fallback chains with additional agent keys', () => {
  591. const projectDir = path.join(tempDir, 'project');
  592. const projectConfigDir = path.join(projectDir, '.opencode');
  593. fs.mkdirSync(projectConfigDir, { recursive: true });
  594. fs.writeFileSync(
  595. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  596. JSON.stringify({
  597. fallback: {
  598. chains: {
  599. writing: ['openai/gpt-5.5'],
  600. },
  601. },
  602. }),
  603. );
  604. const config = loadPluginConfig(projectDir);
  605. expect(config.fallback?.chains.writing).toEqual(['openai/gpt-5.5']);
  606. });
  607. });
  608. describe('preset resolution', () => {
  609. let tempDir: string;
  610. let originalEnv: typeof process.env;
  611. beforeEach(() => {
  612. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-test-'));
  613. originalEnv = { ...process.env };
  614. delete process.env.OPENCODE_CONFIG_DIR;
  615. process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
  616. });
  617. afterEach(() => {
  618. fs.rmSync(tempDir, { recursive: true, force: true });
  619. process.env = originalEnv;
  620. });
  621. test('backward compatibility: config with only agents works unchanged', () => {
  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, 'oh-my-opencode-slim.json'),
  627. JSON.stringify({
  628. agents: { oracle: { model: 'direct-model' } },
  629. }),
  630. );
  631. const config = loadPluginConfig(projectDir);
  632. expect(config.agents?.oracle?.model).toBe('direct-model');
  633. expect(config.preset).toBeUndefined();
  634. });
  635. test("preset applied: preset + presets returns preset's agents", () => {
  636. const projectDir = path.join(tempDir, 'project');
  637. const projectConfigDir = path.join(projectDir, '.opencode');
  638. fs.mkdirSync(projectConfigDir, { recursive: true });
  639. fs.writeFileSync(
  640. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  641. JSON.stringify({
  642. preset: 'fast',
  643. presets: {
  644. fast: { oracle: { model: 'fast-model' } },
  645. },
  646. }),
  647. );
  648. const config = loadPluginConfig(projectDir);
  649. expect(config.agents?.oracle?.model).toBe('fast-model');
  650. });
  651. test('root agents override preset agents', () => {
  652. const projectDir = path.join(tempDir, 'project');
  653. const projectConfigDir = path.join(projectDir, '.opencode');
  654. fs.mkdirSync(projectConfigDir, { recursive: true });
  655. fs.writeFileSync(
  656. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  657. JSON.stringify({
  658. preset: 'fast',
  659. presets: {
  660. fast: {
  661. oracle: { model: 'fast-model', temperature: 0.1 },
  662. explorer: { model: 'explorer-model' },
  663. },
  664. },
  665. agents: {
  666. oracle: { temperature: 0.9 }, // Should override preset temperature
  667. },
  668. }),
  669. );
  670. const config = loadPluginConfig(projectDir);
  671. expect(config.agents?.oracle?.model).toBe('fast-model');
  672. expect(config.agents?.oracle?.temperature).toBe(0.9);
  673. expect(config.agents?.explorer?.model).toBe('explorer-model');
  674. });
  675. test('missing preset: preset set but not in presets -> returns empty/root agents', () => {
  676. const projectDir = path.join(tempDir, 'project');
  677. const projectConfigDir = path.join(projectDir, '.opencode');
  678. fs.mkdirSync(projectConfigDir, { recursive: true });
  679. fs.writeFileSync(
  680. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  681. JSON.stringify({
  682. preset: 'nonexistent',
  683. presets: {
  684. other: { oracle: { model: 'other' } },
  685. },
  686. agents: { oracle: { model: 'root' } },
  687. }),
  688. );
  689. const config = loadPluginConfig(projectDir);
  690. expect(config.agents?.oracle?.model).toBe('root');
  691. });
  692. test('preset only: no root agents, just preset works', () => {
  693. const projectDir = path.join(tempDir, 'project');
  694. const projectConfigDir = path.join(projectDir, '.opencode');
  695. fs.mkdirSync(projectConfigDir, { recursive: true });
  696. fs.writeFileSync(
  697. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  698. JSON.stringify({
  699. preset: 'dev',
  700. presets: {
  701. dev: { oracle: { model: 'dev-model' } },
  702. },
  703. }),
  704. );
  705. const config = loadPluginConfig(projectDir);
  706. expect(config.agents?.oracle?.model).toBe('dev-model');
  707. });
  708. test('invalid preset shape: bad agent config in preset fails schema validation', () => {
  709. const projectDir = path.join(tempDir, 'project');
  710. const projectConfigDir = path.join(projectDir, '.opencode');
  711. fs.mkdirSync(projectConfigDir, { recursive: true });
  712. // preset agents with invalid temperature
  713. fs.writeFileSync(
  714. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  715. JSON.stringify({
  716. preset: 'invalid',
  717. presets: {
  718. invalid: { oracle: { temperature: 5 } },
  719. },
  720. }),
  721. );
  722. // Should return empty config due to validation failure
  723. expect(loadPluginConfig(projectDir)).toEqual({});
  724. });
  725. test('nonexistent preset from config warns and falls back to root agents', () => {
  726. const projectDir = path.join(tempDir, 'project');
  727. const projectConfigDir = path.join(projectDir, '.opencode');
  728. fs.mkdirSync(projectConfigDir, { recursive: true });
  729. fs.writeFileSync(
  730. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  731. JSON.stringify({
  732. preset: 'nonexistent',
  733. presets: {
  734. other: { oracle: { model: 'other' } },
  735. },
  736. agents: { oracle: { model: 'root' } },
  737. }),
  738. );
  739. const consoleWarnSpy = spyOn(console, 'warn');
  740. const config = loadPluginConfig(projectDir);
  741. expect(config.agents?.oracle?.model).toBe('root');
  742. expect(consoleWarnSpy).toHaveBeenCalled();
  743. const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
  744. expect(warningMessage).toContain('Preset "nonexistent" not found');
  745. expect(warningMessage).toContain('Available presets: other');
  746. });
  747. test('nonexistent preset with no root agents returns empty agents', () => {
  748. const projectDir = path.join(tempDir, 'project');
  749. const projectConfigDir = path.join(projectDir, '.opencode');
  750. fs.mkdirSync(projectConfigDir, { recursive: true });
  751. fs.writeFileSync(
  752. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  753. JSON.stringify({
  754. preset: 'nonexistent',
  755. presets: {
  756. other: { oracle: { model: 'other' } },
  757. },
  758. }),
  759. );
  760. const consoleWarnSpy = spyOn(console, 'warn');
  761. const config = loadPluginConfig(projectDir);
  762. expect(config.agents).toBeUndefined();
  763. expect(consoleWarnSpy).toHaveBeenCalled();
  764. const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
  765. expect(warningMessage).toContain('Preset "nonexistent" not found');
  766. });
  767. test('options from preset are deep-merged with root agents', () => {
  768. const projectDir = path.join(tempDir, 'project');
  769. const projectConfigDir = path.join(projectDir, '.opencode');
  770. fs.mkdirSync(projectConfigDir, { recursive: true });
  771. fs.writeFileSync(
  772. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  773. JSON.stringify({
  774. preset: 'openai',
  775. presets: {
  776. openai: {
  777. oracle: {
  778. model: 'openai/gpt-5.5',
  779. options: { textVerbosity: 'low' },
  780. },
  781. },
  782. },
  783. agents: {
  784. oracle: {
  785. options: { reasoningEffort: 'medium' },
  786. },
  787. },
  788. }),
  789. );
  790. const config = loadPluginConfig(projectDir);
  791. expect(config.agents?.oracle?.model).toBe('openai/gpt-5.5');
  792. // deepMerge should combine both option keys
  793. expect(config.agents?.oracle?.options).toEqual({
  794. textVerbosity: 'low',
  795. reasoningEffort: 'medium',
  796. });
  797. });
  798. test('options from preset only work without root agents', () => {
  799. const projectDir = path.join(tempDir, 'project');
  800. const projectConfigDir = path.join(projectDir, '.opencode');
  801. fs.mkdirSync(projectConfigDir, { recursive: true });
  802. fs.writeFileSync(
  803. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  804. JSON.stringify({
  805. preset: 'anthropic-thinking',
  806. presets: {
  807. 'anthropic-thinking': {
  808. oracle: {
  809. model: 'anthropic/claude-sonnet-4-6',
  810. options: {
  811. thinking: { type: 'enabled', budgetTokens: 16000 },
  812. },
  813. },
  814. },
  815. },
  816. }),
  817. );
  818. const config = loadPluginConfig(projectDir);
  819. expect(config.agents?.oracle?.model).toBe('anthropic/claude-sonnet-4-6');
  820. expect(config.agents?.oracle?.options).toEqual({
  821. thinking: { type: 'enabled', budgetTokens: 16000 },
  822. });
  823. });
  824. test('root options override preset options for same key', () => {
  825. const projectDir = path.join(tempDir, 'project');
  826. const projectConfigDir = path.join(projectDir, '.opencode');
  827. fs.mkdirSync(projectConfigDir, { recursive: true });
  828. fs.writeFileSync(
  829. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  830. JSON.stringify({
  831. preset: 'concise',
  832. presets: {
  833. concise: {
  834. oracle: {
  835. model: 'openai/gpt-5.5',
  836. options: { textVerbosity: 'low' },
  837. },
  838. },
  839. },
  840. agents: {
  841. oracle: {
  842. options: { textVerbosity: 'high' },
  843. },
  844. },
  845. }),
  846. );
  847. const config = loadPluginConfig(projectDir);
  848. expect(config.agents?.oracle?.model).toBe('openai/gpt-5.5');
  849. // root wins over preset for same key
  850. expect(config.agents?.oracle?.options).toEqual({
  851. textVerbosity: 'high',
  852. });
  853. });
  854. });
  855. describe('environment variable preset override', () => {
  856. let tempDir: string;
  857. let originalEnv: typeof process.env;
  858. beforeEach(() => {
  859. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-preset-test-'));
  860. originalEnv = { ...process.env };
  861. delete process.env.OPENCODE_CONFIG_DIR;
  862. process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
  863. });
  864. afterEach(() => {
  865. fs.rmSync(tempDir, { recursive: true, force: true });
  866. process.env = originalEnv;
  867. });
  868. test('Env var overrides preset from config file', () => {
  869. const projectDir = path.join(tempDir, 'project');
  870. const projectConfigDir = path.join(projectDir, '.opencode');
  871. fs.mkdirSync(projectConfigDir, { recursive: true });
  872. fs.writeFileSync(
  873. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  874. JSON.stringify({
  875. preset: 'config-preset',
  876. presets: {
  877. 'config-preset': { oracle: { model: 'config-model' } },
  878. 'env-preset': { oracle: { model: 'env-model' } },
  879. },
  880. }),
  881. );
  882. process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
  883. const config = loadPluginConfig(projectDir);
  884. expect(config.preset).toBe('env-preset');
  885. expect(config.agents?.oracle?.model).toBe('env-model');
  886. });
  887. test('Env var works when config has no preset', () => {
  888. const projectDir = path.join(tempDir, 'project');
  889. const projectConfigDir = path.join(projectDir, '.opencode');
  890. fs.mkdirSync(projectConfigDir, { recursive: true });
  891. fs.writeFileSync(
  892. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  893. JSON.stringify({
  894. presets: {
  895. 'env-preset': { oracle: { model: 'env-model' } },
  896. },
  897. }),
  898. );
  899. process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
  900. const config = loadPluginConfig(projectDir);
  901. expect(config.preset).toBe('env-preset');
  902. expect(config.agents?.oracle?.model).toBe('env-model');
  903. });
  904. test('Env var is ignored if empty string', () => {
  905. const projectDir = path.join(tempDir, 'project');
  906. const projectConfigDir = path.join(projectDir, '.opencode');
  907. fs.mkdirSync(projectConfigDir, { recursive: true });
  908. fs.writeFileSync(
  909. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  910. JSON.stringify({
  911. preset: 'config-preset',
  912. presets: {
  913. 'config-preset': { oracle: { model: 'config-model' } },
  914. },
  915. }),
  916. );
  917. process.env.OH_MY_OPENCODE_SLIM_PRESET = '';
  918. const config = loadPluginConfig(projectDir);
  919. expect(config.preset).toBe('config-preset');
  920. expect(config.agents?.oracle?.model).toBe('config-model');
  921. });
  922. test('Env var is ignored if undefined', () => {
  923. const projectDir = path.join(tempDir, 'project');
  924. const projectConfigDir = path.join(projectDir, '.opencode');
  925. fs.mkdirSync(projectConfigDir, { recursive: true });
  926. fs.writeFileSync(
  927. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  928. JSON.stringify({
  929. preset: 'config-preset',
  930. presets: {
  931. 'config-preset': { oracle: { model: 'config-model' } },
  932. },
  933. }),
  934. );
  935. delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
  936. const config = loadPluginConfig(projectDir);
  937. expect(config.preset).toBe('config-preset');
  938. expect(config.agents?.oracle?.model).toBe('config-model');
  939. });
  940. test('Env var with nonexistent preset warns and falls back', () => {
  941. const projectDir = path.join(tempDir, 'project');
  942. const projectConfigDir = path.join(projectDir, '.opencode');
  943. fs.mkdirSync(projectConfigDir, { recursive: true });
  944. fs.writeFileSync(
  945. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  946. JSON.stringify({
  947. preset: 'config-preset',
  948. presets: {
  949. 'config-preset': { oracle: { model: 'config-model' } },
  950. },
  951. agents: { oracle: { model: 'fallback' } },
  952. }),
  953. );
  954. process.env.OH_MY_OPENCODE_SLIM_PRESET = 'typo-preset';
  955. const consoleWarnSpy = spyOn(console, 'warn');
  956. const config = loadPluginConfig(projectDir);
  957. expect(config.preset).toBe('typo-preset');
  958. expect(config.agents?.oracle?.model).toBe('fallback');
  959. expect(consoleWarnSpy).toHaveBeenCalled();
  960. const calls = consoleWarnSpy.mock.calls as string[][];
  961. const warningMessage =
  962. calls.find((call) => call[0]?.includes('typo-preset'))?.[0] || '';
  963. expect(warningMessage).toContain('Preset "typo-preset" not found');
  964. expect(warningMessage).toContain('environment variable');
  965. expect(warningMessage).toContain('config-preset');
  966. });
  967. });
  968. describe('JSONC config support', () => {
  969. let tempDir: string;
  970. let originalEnv: typeof process.env;
  971. beforeEach(() => {
  972. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonc-test-'));
  973. originalEnv = { ...process.env };
  974. delete process.env.OPENCODE_CONFIG_DIR;
  975. process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
  976. });
  977. afterEach(() => {
  978. fs.rmSync(tempDir, { recursive: true, force: true });
  979. process.env = originalEnv;
  980. });
  981. test('loads .jsonc file with single-line comments', () => {
  982. const projectDir = path.join(tempDir, 'project');
  983. const projectConfigDir = path.join(projectDir, '.opencode');
  984. fs.mkdirSync(projectConfigDir, { recursive: true });
  985. fs.writeFileSync(
  986. path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
  987. `{
  988. // This is a comment
  989. "agents": {
  990. "oracle": { "model": "test/model" } // inline comment
  991. }
  992. }`,
  993. );
  994. const config = loadPluginConfig(projectDir);
  995. expect(config.agents?.oracle?.model).toBe('test/model');
  996. });
  997. test('loads .jsonc file with multi-line comments', () => {
  998. const projectDir = path.join(tempDir, 'project');
  999. const projectConfigDir = path.join(projectDir, '.opencode');
  1000. fs.mkdirSync(projectConfigDir, { recursive: true });
  1001. fs.writeFileSync(
  1002. path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
  1003. `{
  1004. /* Multi-line
  1005. comment block */
  1006. "agents": {
  1007. "explorer": { "model": "explorer-model" }
  1008. }
  1009. }`,
  1010. );
  1011. const config = loadPluginConfig(projectDir);
  1012. expect(config.agents?.explorer?.model).toBe('explorer-model');
  1013. });
  1014. test('loads .jsonc file with trailing commas', () => {
  1015. const projectDir = path.join(tempDir, 'project');
  1016. const projectConfigDir = path.join(projectDir, '.opencode');
  1017. fs.mkdirSync(projectConfigDir, { recursive: true });
  1018. fs.writeFileSync(
  1019. path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
  1020. `{
  1021. "agents": {
  1022. "oracle": { "model": "test-model", },
  1023. },
  1024. }`,
  1025. );
  1026. const config = loadPluginConfig(projectDir);
  1027. expect(config.agents?.oracle?.model).toBe('test-model');
  1028. });
  1029. test('prefers .jsonc over .json when both exist', () => {
  1030. const projectDir = path.join(tempDir, 'project');
  1031. const projectConfigDir = path.join(projectDir, '.opencode');
  1032. fs.mkdirSync(projectConfigDir, { recursive: true });
  1033. // Create both files
  1034. fs.writeFileSync(
  1035. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  1036. JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
  1037. );
  1038. fs.writeFileSync(
  1039. path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
  1040. `{
  1041. // JSONC version
  1042. "agents": { "oracle": { "model": "jsonc-model" } }
  1043. }`,
  1044. );
  1045. const config = loadPluginConfig(projectDir);
  1046. expect(config.agents?.oracle?.model).toBe('jsonc-model');
  1047. });
  1048. test('falls back to .json when .jsonc does not exist', () => {
  1049. const projectDir = path.join(tempDir, 'project');
  1050. const projectConfigDir = path.join(projectDir, '.opencode');
  1051. fs.mkdirSync(projectConfigDir, { recursive: true });
  1052. // Only create .json file
  1053. fs.writeFileSync(
  1054. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  1055. JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
  1056. );
  1057. const config = loadPluginConfig(projectDir);
  1058. expect(config.agents?.oracle?.model).toBe('json-model');
  1059. });
  1060. test('loads user config from .jsonc', () => {
  1061. const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
  1062. fs.mkdirSync(userOpencodeDir, { recursive: true });
  1063. fs.writeFileSync(
  1064. path.join(userOpencodeDir, 'oh-my-opencode-slim.jsonc'),
  1065. `{
  1066. // User config with comments
  1067. "agents": { "librarian": { "model": "user-librarian" } }
  1068. }`,
  1069. );
  1070. const projectDir = path.join(tempDir, 'project');
  1071. fs.mkdirSync(projectDir, { recursive: true });
  1072. const config = loadPluginConfig(projectDir);
  1073. expect(config.agents?.librarian?.model).toBe('user-librarian');
  1074. });
  1075. test('merges user .jsonc with project .jsonc', () => {
  1076. const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
  1077. fs.mkdirSync(userOpencodeDir, { recursive: true });
  1078. fs.writeFileSync(
  1079. path.join(userOpencodeDir, 'oh-my-opencode-slim.jsonc'),
  1080. `{
  1081. // User config
  1082. "agents": {
  1083. "oracle": { "model": "user-oracle", "temperature": 0.5 }
  1084. }
  1085. }`,
  1086. );
  1087. const projectDir = path.join(tempDir, 'project');
  1088. const projectConfigDir = path.join(projectDir, '.opencode');
  1089. fs.mkdirSync(projectConfigDir, { recursive: true });
  1090. fs.writeFileSync(
  1091. path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
  1092. `{
  1093. // Project config
  1094. "agents": { "oracle": { "temperature": 0.8 } }
  1095. }`,
  1096. );
  1097. const config = loadPluginConfig(projectDir);
  1098. expect(config.agents?.oracle?.model).toBe('user-oracle');
  1099. expect(config.agents?.oracle?.temperature).toBe(0.8);
  1100. });
  1101. test('handles complex JSONC with mixed comments and trailing commas', () => {
  1102. const projectDir = path.join(tempDir, 'project');
  1103. const projectConfigDir = path.join(projectDir, '.opencode');
  1104. fs.mkdirSync(projectConfigDir, { recursive: true });
  1105. fs.writeFileSync(
  1106. path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
  1107. `{
  1108. // Main configuration
  1109. "preset": "dev",
  1110. /* Presets definition */
  1111. "presets": {
  1112. "dev": {
  1113. // Development agents
  1114. "oracle": { "model": "dev-oracle", },
  1115. "explorer": { "model": "dev-explorer", },
  1116. },
  1117. },
  1118. "tmux": {
  1119. "enabled": true, // Enable tmux
  1120. "layout": "main-vertical",
  1121. },
  1122. }`,
  1123. );
  1124. const config = loadPluginConfig(projectDir);
  1125. expect(config.preset).toBe('dev');
  1126. expect(config.agents?.oracle?.model).toBe('dev-oracle');
  1127. expect(config.agents?.explorer?.model).toBe('dev-explorer');
  1128. expect(config.tmux?.enabled).toBe(true);
  1129. expect(config.tmux?.layout).toBe('main-vertical');
  1130. });
  1131. });
  1132. describe('loadAgentPrompt', () => {
  1133. let tempDir: string;
  1134. let originalEnv: typeof process.env;
  1135. beforeEach(() => {
  1136. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-test-'));
  1137. originalEnv = { ...process.env };
  1138. delete process.env.OPENCODE_CONFIG_DIR;
  1139. process.env.XDG_CONFIG_HOME = tempDir;
  1140. });
  1141. afterEach(() => {
  1142. fs.rmSync(tempDir, { recursive: true, force: true });
  1143. process.env = originalEnv;
  1144. });
  1145. test('returns empty object when no prompt files exist', () => {
  1146. const result = loadAgentPrompt('oracle');
  1147. expect(result).toEqual({});
  1148. });
  1149. test('loads replacement prompt from {agent}.md', () => {
  1150. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1151. fs.mkdirSync(promptsDir, { recursive: true });
  1152. fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
  1153. const result = loadAgentPrompt('oracle');
  1154. expect(result.prompt).toBe('replacement prompt');
  1155. expect(result.appendPrompt).toBeUndefined();
  1156. });
  1157. test('loads append prompt from {agent}_append.md', () => {
  1158. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1159. fs.mkdirSync(promptsDir, { recursive: true });
  1160. fs.writeFileSync(
  1161. path.join(promptsDir, 'oracle_append.md'),
  1162. 'append prompt',
  1163. );
  1164. const result = loadAgentPrompt('oracle');
  1165. expect(result.prompt).toBeUndefined();
  1166. expect(result.appendPrompt).toBe('append prompt');
  1167. });
  1168. test('loads both replacement and append prompts', () => {
  1169. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1170. fs.mkdirSync(promptsDir, { recursive: true });
  1171. fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
  1172. fs.writeFileSync(
  1173. path.join(promptsDir, 'oracle_append.md'),
  1174. 'append prompt',
  1175. );
  1176. const result = loadAgentPrompt('oracle');
  1177. expect(result.prompt).toBe('replacement prompt');
  1178. expect(result.appendPrompt).toBe('append prompt');
  1179. });
  1180. test('handles file read errors gracefully', () => {
  1181. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1182. fs.mkdirSync(promptsDir, { recursive: true });
  1183. const promptPath = path.join(promptsDir, 'error-agent.md');
  1184. fs.writeFileSync(promptPath, 'content');
  1185. const consoleWarnSpy = spyOn(console, 'warn');
  1186. // Use a unique agent name and check for it specifically
  1187. const originalReadFileSync = fs.readFileSync;
  1188. const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
  1189. ...args: Parameters<typeof fs.readFileSync>
  1190. ) => {
  1191. const [p] = args;
  1192. if (typeof p === 'string' && p.includes('error-agent.md')) {
  1193. throw new Error('Read error');
  1194. }
  1195. return originalReadFileSync(...args);
  1196. }) as typeof fs.readFileSync);
  1197. try {
  1198. const result = loadAgentPrompt('error-agent');
  1199. expect(result.prompt).toBeUndefined();
  1200. const warningFound = consoleWarnSpy.mock.calls.some((call) =>
  1201. (call[0] as string).includes('Error reading prompt file'),
  1202. );
  1203. expect(warningFound).toBe(true);
  1204. } finally {
  1205. readSpy.mockRestore();
  1206. }
  1207. });
  1208. test('prefers preset prompt files over root prompts', () => {
  1209. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1210. const presetDir = path.join(promptsDir, 'test');
  1211. fs.mkdirSync(presetDir, { recursive: true });
  1212. fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
  1213. fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
  1214. fs.writeFileSync(
  1215. path.join(promptsDir, 'oracle_append.md'),
  1216. 'root append prompt',
  1217. );
  1218. fs.writeFileSync(
  1219. path.join(presetDir, 'oracle_append.md'),
  1220. 'preset append prompt',
  1221. );
  1222. const result = loadAgentPrompt('oracle', 'test');
  1223. expect(result.prompt).toBe('preset replacement');
  1224. expect(result.appendPrompt).toBe('preset append prompt');
  1225. });
  1226. test('falls back to root prompt files when preset files are missing', () => {
  1227. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1228. const presetDir = path.join(promptsDir, 'test');
  1229. fs.mkdirSync(presetDir, { recursive: true });
  1230. fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
  1231. fs.writeFileSync(
  1232. path.join(promptsDir, 'oracle_append.md'),
  1233. 'root append prompt',
  1234. );
  1235. const result = loadAgentPrompt('oracle', 'test');
  1236. expect(result.prompt).toBe('root replacement');
  1237. expect(result.appendPrompt).toBe('root append prompt');
  1238. });
  1239. test('falls back independently between preset and root files', () => {
  1240. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1241. const presetDir = path.join(promptsDir, 'test');
  1242. fs.mkdirSync(presetDir, { recursive: true });
  1243. fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
  1244. fs.writeFileSync(
  1245. path.join(promptsDir, 'oracle_append.md'),
  1246. 'root append prompt',
  1247. );
  1248. const result = loadAgentPrompt('oracle', 'test');
  1249. expect(result.prompt).toBe('preset replacement');
  1250. expect(result.appendPrompt).toBe('root append prompt');
  1251. });
  1252. test('ignores unsafe preset names for prompt lookup', () => {
  1253. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1254. fs.mkdirSync(promptsDir, { recursive: true });
  1255. fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
  1256. const result = loadAgentPrompt('oracle', '../test');
  1257. expect(result.prompt).toBe('root replacement');
  1258. expect(result.appendPrompt).toBeUndefined();
  1259. });
  1260. test('falls back to root when preset prompt file read fails', () => {
  1261. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1262. const presetDir = path.join(promptsDir, 'test');
  1263. fs.mkdirSync(presetDir, { recursive: true });
  1264. const presetPromptPath = path.join(presetDir, 'oracle.md');
  1265. fs.writeFileSync(presetPromptPath, 'preset replacement');
  1266. fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
  1267. const consoleWarnSpy = spyOn(console, 'warn');
  1268. const originalReadFileSync = fs.readFileSync;
  1269. const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
  1270. ...args: Parameters<typeof fs.readFileSync>
  1271. ) => {
  1272. const [p] = args;
  1273. if (typeof p === 'string' && p === presetPromptPath) {
  1274. throw new Error('Preset read error');
  1275. }
  1276. return originalReadFileSync(...args);
  1277. }) as typeof fs.readFileSync);
  1278. try {
  1279. const result = loadAgentPrompt('oracle', 'test');
  1280. expect(result.prompt).toBe('root replacement');
  1281. expect(consoleWarnSpy).toHaveBeenCalled();
  1282. } finally {
  1283. readSpy.mockRestore();
  1284. }
  1285. });
  1286. test('works with XDG_CONFIG_HOME environment variable', () => {
  1287. const customConfigHome = path.join(tempDir, 'custom-xdg');
  1288. process.env.XDG_CONFIG_HOME = customConfigHome;
  1289. const promptsDir = path.join(
  1290. customConfigHome,
  1291. 'opencode',
  1292. 'oh-my-opencode-slim',
  1293. );
  1294. fs.mkdirSync(promptsDir, { recursive: true });
  1295. fs.writeFileSync(path.join(promptsDir, 'xdg-agent.md'), 'xdg prompt');
  1296. const result = loadAgentPrompt('xdg-agent');
  1297. expect(result.prompt).toBe('xdg prompt');
  1298. });
  1299. test('respects OPENCODE_CONFIG_DIR for prompt location', () => {
  1300. const customDir = fs.mkdtempSync(
  1301. path.join(os.tmpdir(), 'omc-prompt-config-'),
  1302. );
  1303. process.env.OPENCODE_CONFIG_DIR = customDir;
  1304. const promptsDir = path.join(customDir, 'oh-my-opencode-slim');
  1305. fs.mkdirSync(promptsDir, { recursive: true });
  1306. fs.writeFileSync(
  1307. path.join(promptsDir, 'oracle.md'),
  1308. 'prompt from OPENCODE_CONFIG_DIR dir',
  1309. );
  1310. const result = loadAgentPrompt('oracle');
  1311. expect(result.prompt).toBe('prompt from OPENCODE_CONFIG_DIR dir');
  1312. fs.rmSync(customDir, { recursive: true, force: true });
  1313. });
  1314. test('falls back to default prompt dir when OPENCODE_CONFIG_DIR has no prompt', () => {
  1315. const customDir = fs.mkdtempSync(
  1316. path.join(os.tmpdir(), 'omc-prompt-config-empty-'),
  1317. );
  1318. process.env.OPENCODE_CONFIG_DIR = customDir;
  1319. const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
  1320. fs.mkdirSync(promptsDir, { recursive: true });
  1321. fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'fallback prompt');
  1322. const result = loadAgentPrompt('oracle');
  1323. expect(result.prompt).toBe('fallback prompt');
  1324. fs.rmSync(customDir, { recursive: true, force: true });
  1325. });
  1326. });
  1327. describe('env variable interpolation', () => {
  1328. let tempDir: string;
  1329. let originalEnv: typeof process.env;
  1330. beforeEach(() => {
  1331. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-interp-test-'));
  1332. originalEnv = { ...process.env };
  1333. delete process.env.OPENCODE_CONFIG_DIR;
  1334. process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
  1335. });
  1336. afterEach(() => {
  1337. fs.rmSync(tempDir, { recursive: true, force: true });
  1338. process.env = originalEnv;
  1339. });
  1340. test('basic substitution: {env:FOO} resolves to the value of FOO', () => {
  1341. process.env.FOO = 'foo-model';
  1342. const projectDir = path.join(tempDir, 'project');
  1343. const projectConfigDir = path.join(projectDir, '.opencode');
  1344. fs.mkdirSync(projectConfigDir, { recursive: true });
  1345. fs.writeFileSync(
  1346. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  1347. JSON.stringify({
  1348. agents: { oracle: { model: '{env:FOO}' } },
  1349. }),
  1350. );
  1351. const config = loadPluginConfig(projectDir);
  1352. expect(config.agents?.oracle?.model).toBe('foo-model');
  1353. });
  1354. test('multiple variables: multiple {env:...} tokens resolve', () => {
  1355. process.env.FOO = 'foo-model';
  1356. process.env.BAR = 'bar-model';
  1357. const projectDir = path.join(tempDir, 'project');
  1358. const projectConfigDir = path.join(projectDir, '.opencode');
  1359. fs.mkdirSync(projectConfigDir, { recursive: true });
  1360. fs.writeFileSync(
  1361. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  1362. JSON.stringify({
  1363. agents: {
  1364. oracle: { model: '{env:FOO}' },
  1365. explorer: { model: '{env:BAR}' },
  1366. },
  1367. }),
  1368. );
  1369. const config = loadPluginConfig(projectDir);
  1370. expect(config.agents?.oracle?.model).toBe('foo-model');
  1371. expect(config.agents?.explorer?.model).toBe('bar-model');
  1372. });
  1373. test('undefined variable: {env:NONEXISTENT} resolves to empty string', () => {
  1374. delete process.env.NONEXISTENT;
  1375. const projectDir = path.join(tempDir, 'project');
  1376. const projectConfigDir = path.join(projectDir, '.opencode');
  1377. fs.mkdirSync(projectConfigDir, { recursive: true });
  1378. fs.writeFileSync(
  1379. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  1380. JSON.stringify({
  1381. agents: { oracle: { model: '{env:NONEXISTENT}' } },
  1382. }),
  1383. );
  1384. const config = loadPluginConfig(projectDir);
  1385. expect(config.agents?.oracle?.model).toBe('');
  1386. });
  1387. test('no interpolation needed: config without {env:...} works unchanged', () => {
  1388. const projectDir = path.join(tempDir, 'project');
  1389. const projectConfigDir = path.join(projectDir, '.opencode');
  1390. fs.mkdirSync(projectConfigDir, { recursive: true });
  1391. fs.writeFileSync(
  1392. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  1393. JSON.stringify({
  1394. agents: { oracle: { model: 'foo-model' } },
  1395. }),
  1396. );
  1397. const config = loadPluginConfig(projectDir);
  1398. expect(config.agents?.oracle?.model).toBe('foo-model');
  1399. });
  1400. test('partial string: "prefix-{env:FOO}-suffix" resolves correctly', () => {
  1401. process.env.FOO = 'foo';
  1402. const projectDir = path.join(tempDir, 'project');
  1403. const projectConfigDir = path.join(projectDir, '.opencode');
  1404. fs.mkdirSync(projectConfigDir, { recursive: true });
  1405. fs.writeFileSync(
  1406. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  1407. JSON.stringify({
  1408. agents: { oracle: { model: 'prefix-{env:FOO}-suffix' } },
  1409. }),
  1410. );
  1411. const config = loadPluginConfig(projectDir);
  1412. expect(config.agents?.oracle?.model).toBe('prefix-foo-suffix');
  1413. });
  1414. test('works in JSONC files with comments', () => {
  1415. process.env.FOO = 'foo-model';
  1416. const projectDir = path.join(tempDir, 'project');
  1417. const projectConfigDir = path.join(projectDir, '.opencode');
  1418. fs.mkdirSync(projectConfigDir, { recursive: true });
  1419. fs.writeFileSync(
  1420. path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
  1421. `{
  1422. // Use env variable for model
  1423. "agents": { "oracle": { "model": "{env:FOO}" } }
  1424. }`,
  1425. );
  1426. const config = loadPluginConfig(projectDir);
  1427. expect(config.agents?.oracle?.model).toBe('foo-model');
  1428. });
  1429. test('multiple env vars in a single value', () => {
  1430. process.env.FOO = 'foo';
  1431. process.env.BAR = 'bar';
  1432. const projectDir = path.join(tempDir, 'project');
  1433. const projectConfigDir = path.join(projectDir, '.opencode');
  1434. fs.mkdirSync(projectConfigDir, { recursive: true });
  1435. fs.writeFileSync(
  1436. path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
  1437. JSON.stringify({
  1438. agents: {
  1439. oracle: { model: '{env:FOO}/{env:BAR}' },
  1440. },
  1441. }),
  1442. );
  1443. const config = loadPluginConfig(projectDir);
  1444. expect(config.agents?.oracle?.model).toBe('foo/bar');
  1445. });
  1446. });