loader.test.ts 52 KB

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