config-io.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. /// <reference types="bun-types" />
  2. import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
  3. import {
  4. existsSync,
  5. mkdirSync,
  6. mkdtempSync,
  7. readFileSync,
  8. rmSync,
  9. writeFileSync,
  10. } from 'node:fs';
  11. import { tmpdir } from 'node:os';
  12. import { join } from 'node:path';
  13. import {
  14. addPluginToOpenCodeConfig,
  15. addPluginToOpenCodeTuiConfig,
  16. detectCurrentConfig,
  17. disableDefaultAgents,
  18. enableLspByDefault,
  19. parseConfig,
  20. parseConfigFile,
  21. stripJsonComments,
  22. writeConfig,
  23. writeLiteConfig,
  24. } from './config-io';
  25. import * as paths from './paths';
  26. describe('config-io', () => {
  27. let tmpDir: string;
  28. const originalEnv = { ...process.env };
  29. const originalArgv = [...process.argv];
  30. beforeEach(() => {
  31. tmpDir = mkdtempSync(join(tmpdir(), 'opencode-io-test-'));
  32. delete process.env.OPENCODE_CONFIG_DIR;
  33. delete process.env.OPENCODE_TUI_CONFIG;
  34. process.env.XDG_CONFIG_HOME = tmpDir;
  35. });
  36. afterEach(() => {
  37. process.env = { ...originalEnv };
  38. process.argv = [...originalArgv];
  39. if (tmpDir && existsSync(tmpDir)) {
  40. rmSync(tmpDir, { recursive: true, force: true });
  41. }
  42. mock.restore();
  43. });
  44. function writePackageJson(dir: string, version?: string): void {
  45. mkdirSync(dir, { recursive: true });
  46. writeFileSync(
  47. join(dir, 'package.json'),
  48. JSON.stringify({
  49. name: 'oh-my-opencode-slim',
  50. ...(version ? { version } : {}),
  51. }),
  52. );
  53. }
  54. test('stripJsonComments strips comments and trailing commas', () => {
  55. const jsonc = `{
  56. // comment
  57. "a": 1, /* multi
  58. line */
  59. "b": [2,],
  60. }`;
  61. const stripped = stripJsonComments(jsonc);
  62. expect(JSON.parse(stripped)).toEqual({ a: 1, b: [2] });
  63. });
  64. test('parseConfigFile parses valid JSON', () => {
  65. const path = join(tmpDir, 'test.json');
  66. writeFileSync(path, '{"a": 1}');
  67. const result = parseConfigFile(path);
  68. expect(result.config).toEqual({ a: 1 } as any);
  69. expect(result.error).toBeUndefined();
  70. });
  71. test('parseConfigFile returns null for non-existent file', () => {
  72. const result = parseConfigFile(join(tmpDir, 'nonexistent.json'));
  73. expect(result.config).toBeNull();
  74. });
  75. test('parseConfigFile returns null for empty or whitespace-only file', () => {
  76. const emptyPath = join(tmpDir, 'empty.json');
  77. writeFileSync(emptyPath, '');
  78. expect(parseConfigFile(emptyPath).config).toBeNull();
  79. const whitespacePath = join(tmpDir, 'whitespace.json');
  80. writeFileSync(whitespacePath, ' \n ');
  81. expect(parseConfigFile(whitespacePath).config).toBeNull();
  82. });
  83. test('parseConfigFile returns error for invalid JSON', () => {
  84. const path = join(tmpDir, 'invalid.json');
  85. writeFileSync(path, '{"a": 1');
  86. const result = parseConfigFile(path);
  87. expect(result.config).toBeNull();
  88. expect(result.error).toBeDefined();
  89. });
  90. test('parseConfig tries .jsonc if .json is missing', () => {
  91. const jsoncPath = join(tmpDir, 'test.jsonc');
  92. writeFileSync(jsoncPath, '{"a": 1}');
  93. // We pass .json path, it should try .jsonc
  94. const result = parseConfig(join(tmpDir, 'test.json'));
  95. expect(result.config).toEqual({ a: 1 } as any);
  96. });
  97. test('writeConfig writes JSON and creates backup', () => {
  98. const path = join(tmpDir, 'test.json');
  99. writeFileSync(path, '{"old": true}');
  100. writeConfig(path, { new: true } as any);
  101. expect(JSON.parse(readFileSync(path, 'utf-8'))).toEqual({ new: true });
  102. expect(JSON.parse(readFileSync(`${path}.bak`, 'utf-8'))).toEqual({
  103. old: true,
  104. });
  105. });
  106. test('addPluginToOpenCodeConfig adds plugin and removes duplicates', async () => {
  107. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  108. paths.ensureConfigDir();
  109. writeFileSync(
  110. configPath,
  111. JSON.stringify({ plugin: ['other', 'oh-my-opencode-slim@1.0.0'] }),
  112. );
  113. process.argv[1] = '';
  114. const result = await addPluginToOpenCodeConfig();
  115. expect(result.success).toBe(true);
  116. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  117. expect(saved.plugin).toContain('oh-my-opencode-slim');
  118. expect(saved.plugin).not.toContain('oh-my-opencode-slim@1.0.0');
  119. expect(saved.plugin.length).toBe(2);
  120. });
  121. test('addPluginToOpenCodeConfig respects OPENCODE_CONFIG_DIR', async () => {
  122. const customConfigDir = join(tmpDir, 'custom-opencode');
  123. const defaultConfigDir = join(tmpDir, 'opencode');
  124. const customConfigPath = join(customConfigDir, 'opencode.jsonc');
  125. const defaultConfigPath = join(defaultConfigDir, 'opencode.json');
  126. process.env.OPENCODE_CONFIG_DIR = customConfigDir;
  127. mkdirSync(customConfigDir, { recursive: true });
  128. mkdirSync(defaultConfigDir, { recursive: true });
  129. writeFileSync(
  130. customConfigPath,
  131. JSON.stringify({ plugin: ['other', 'oh-my-opencode-slim@1.0.0'] }),
  132. );
  133. writeFileSync(defaultConfigPath, JSON.stringify({ plugin: ['default'] }));
  134. process.argv[1] = '';
  135. const result = await addPluginToOpenCodeConfig();
  136. expect(result.success).toBe(true);
  137. expect(result.configPath).toBe(customConfigPath);
  138. const customSaved = JSON.parse(readFileSync(customConfigPath, 'utf-8'));
  139. const defaultSaved = JSON.parse(readFileSync(defaultConfigPath, 'utf-8'));
  140. expect(customSaved.plugin).toEqual(['other', 'oh-my-opencode-slim']);
  141. expect(defaultSaved.plugin).toEqual(['default']);
  142. });
  143. test('addPluginToOpenCodeConfig stores package name for bunx temp paths', async () => {
  144. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  145. const packageRoot = join(
  146. tmpDir,
  147. 'bunx-1000-oh-my-opencode-slim@latest',
  148. 'node_modules',
  149. 'oh-my-opencode-slim',
  150. );
  151. paths.ensureConfigDir();
  152. writeFileSync(configPath, JSON.stringify({ plugin: [] }));
  153. writePackageJson(packageRoot);
  154. process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
  155. const result = await addPluginToOpenCodeConfig();
  156. expect(result.success).toBe(true);
  157. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  158. expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
  159. });
  160. test('addPluginToOpenCodeConfig leaves @latest bunx invocations unpinned', async () => {
  161. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  162. const packageRoot = join(
  163. tmpDir,
  164. 'bunx-1000-oh-my-opencode-slim@latest',
  165. 'node_modules',
  166. 'oh-my-opencode-slim',
  167. );
  168. paths.ensureConfigDir();
  169. writeFileSync(configPath, JSON.stringify({ plugin: [] }));
  170. writePackageJson(packageRoot, '1.2.3');
  171. process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
  172. const result = await addPluginToOpenCodeConfig();
  173. expect(result.success).toBe(true);
  174. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  175. expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
  176. });
  177. test('addPluginToOpenCodeConfig writes the resolved version as an installer-managed tuple', async () => {
  178. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  179. const packageRoot = join(
  180. tmpDir,
  181. 'bunx-1000-oh-my-opencode-slim@beta',
  182. 'node_modules',
  183. 'oh-my-opencode-slim',
  184. );
  185. paths.ensureConfigDir();
  186. writeFileSync(configPath, JSON.stringify({ plugin: [] }));
  187. writePackageJson(packageRoot, '1.2.3');
  188. process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
  189. const result = await addPluginToOpenCodeConfig();
  190. expect(result.success).toBe(true);
  191. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  192. expect(saved.plugin).toEqual([
  193. [
  194. 'oh-my-opencode-slim@1.2.3',
  195. { __ohMyOpencodeSlimManagedByInstaller: true },
  196. ],
  197. ]);
  198. });
  199. test('addPluginToOpenCodeConfig stores local repo path for local dev paths', async () => {
  200. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  201. const packageRoot = join(tmpDir, 'repo');
  202. const localCliPath = join(packageRoot, 'dist', 'cli', 'index.js');
  203. paths.ensureConfigDir();
  204. writeFileSync(configPath, JSON.stringify({ plugin: [] }));
  205. writePackageJson(packageRoot);
  206. process.argv[1] = localCliPath;
  207. const result = await addPluginToOpenCodeConfig();
  208. expect(result.success).toBe(true);
  209. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  210. expect(saved.plugin).toEqual([packageRoot]);
  211. });
  212. test('addPluginToOpenCodeConfig stores local repo path for local paths containing bunx-', async () => {
  213. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  214. const packageRoot = join(tmpDir, 'repo', 'bunx-tools');
  215. const localCliPath = join(packageRoot, 'dist', 'cli', 'index.js');
  216. paths.ensureConfigDir();
  217. writeFileSync(configPath, JSON.stringify({ plugin: [] }));
  218. writePackageJson(packageRoot);
  219. process.argv[1] = localCliPath;
  220. const result = await addPluginToOpenCodeConfig();
  221. expect(result.success).toBe(true);
  222. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  223. expect(saved.plugin).toEqual([packageRoot]);
  224. });
  225. test('addPluginToOpenCodeConfig deduplicates existing local repo path entries', async () => {
  226. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  227. const packageRoot = join(tmpDir, 'repo');
  228. const localCliPath = join(packageRoot, 'dist', 'cli', 'index.js');
  229. paths.ensureConfigDir();
  230. writePackageJson(packageRoot);
  231. writeFileSync(
  232. configPath,
  233. JSON.stringify({ plugin: ['other', packageRoot] }),
  234. );
  235. process.argv[1] = localCliPath;
  236. const result = await addPluginToOpenCodeConfig();
  237. expect(result.success).toBe(true);
  238. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  239. expect(saved.plugin).toEqual(['other', packageRoot]);
  240. });
  241. test('addPluginToOpenCodeConfig preserves non-string plugin entries when refreshing', async () => {
  242. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  243. paths.ensureConfigDir();
  244. process.argv[1] = '';
  245. const objectPlugin = { name: 'some-config-plugin', enabled: true };
  246. writeFileSync(
  247. configPath,
  248. JSON.stringify({
  249. plugin: ['other-plugin', objectPlugin, 'oh-my-opencode-slim@1.0.0'],
  250. }),
  251. );
  252. const result = await addPluginToOpenCodeConfig();
  253. expect(result.success).toBe(true);
  254. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  255. expect(saved.plugin).toContain('oh-my-opencode-slim');
  256. expect(saved.plugin).toContain('other-plugin');
  257. expect(saved.plugin).not.toContain('oh-my-opencode-slim@1.0.0');
  258. // Non-string entries (objects) must survive the plugin refresh
  259. expect(saved.plugin).toContainEqual(objectPlugin);
  260. expect(saved.plugin.length).toBe(3);
  261. });
  262. test('addPluginToOpenCodeConfig removes tuple plugin entries', async () => {
  263. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  264. paths.ensureConfigDir();
  265. writeFileSync(
  266. configPath,
  267. JSON.stringify({
  268. plugin: ['other', ['oh-my-opencode-slim', { enabled: true }]],
  269. }),
  270. );
  271. process.argv[1] = '';
  272. const result = await addPluginToOpenCodeConfig();
  273. expect(result.success).toBe(true);
  274. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  275. expect(saved.plugin).toEqual(['other', 'oh-my-opencode-slim']);
  276. });
  277. test('addPluginToOpenCodeTuiConfig adds plugin to tui.json and removes duplicates', async () => {
  278. const tuiPath = join(tmpDir, 'opencode', 'tui.json');
  279. paths.ensureConfigDir();
  280. writeFileSync(
  281. tuiPath,
  282. JSON.stringify({ plugin: ['other', 'oh-my-opencode-slim@1.0.0'] }),
  283. );
  284. process.argv[1] = '';
  285. const result = await addPluginToOpenCodeTuiConfig();
  286. expect(result.success).toBe(true);
  287. const saved = JSON.parse(readFileSync(tuiPath, 'utf-8'));
  288. expect(saved.plugin).toContain('oh-my-opencode-slim');
  289. expect(saved.plugin).not.toContain('oh-my-opencode-slim@1.0.0');
  290. expect(saved.plugin.length).toBe(2);
  291. });
  292. test('addPluginToOpenCodeTuiConfig stores package name for bunx temp paths', async () => {
  293. const tuiPath = join(tmpDir, 'opencode', 'tui.json');
  294. const packageRoot = join(
  295. tmpDir,
  296. 'bunx-1000-oh-my-opencode-slim@latest',
  297. 'node_modules',
  298. 'oh-my-opencode-slim',
  299. );
  300. paths.ensureConfigDir();
  301. writeFileSync(tuiPath, JSON.stringify({ plugin: [] }));
  302. writePackageJson(packageRoot);
  303. process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
  304. const result = await addPluginToOpenCodeTuiConfig();
  305. expect(result.success).toBe(true);
  306. const saved = JSON.parse(readFileSync(tuiPath, 'utf-8'));
  307. expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
  308. });
  309. test('addPluginToOpenCodeTuiConfig removes tuple plugin entries', async () => {
  310. const tuiPath = join(tmpDir, 'opencode', 'tui.json');
  311. paths.ensureConfigDir();
  312. writeFileSync(
  313. tuiPath,
  314. JSON.stringify({
  315. plugin: ['other', ['oh-my-opencode-slim', { enabled: true }]],
  316. }),
  317. );
  318. process.argv[1] = '';
  319. const result = await addPluginToOpenCodeTuiConfig();
  320. expect(result.success).toBe(true);
  321. const saved = JSON.parse(readFileSync(tuiPath, 'utf-8'));
  322. expect(saved.plugin).toEqual(['other', 'oh-my-opencode-slim']);
  323. });
  324. test('addPluginToOpenCodeTuiConfig honors OPENCODE_TUI_CONFIG', async () => {
  325. const tuiPath = join(tmpDir, 'custom', 'tui.custom.json');
  326. process.env.OPENCODE_TUI_CONFIG = tuiPath;
  327. process.argv[1] = '';
  328. const result = await addPluginToOpenCodeTuiConfig();
  329. expect(result.success).toBe(true);
  330. expect(result.configPath).toBe(tuiPath);
  331. const saved = JSON.parse(readFileSync(tuiPath, 'utf-8'));
  332. expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
  333. });
  334. test('addPluginToOpenCodeTuiConfig does not bypass OPENCODE_TUI_CONFIG for existing default config', async () => {
  335. const defaultTuiPath = join(tmpDir, 'opencode', 'tui.jsonc');
  336. const customTuiPath = join(tmpDir, 'custom', 'tui.json');
  337. paths.ensureConfigDir();
  338. writeFileSync(defaultTuiPath, JSON.stringify({ plugin: ['default'] }));
  339. process.env.OPENCODE_TUI_CONFIG = customTuiPath;
  340. process.argv[1] = '';
  341. const result = await addPluginToOpenCodeTuiConfig();
  342. expect(result.success).toBe(true);
  343. expect(result.configPath).toBe(customTuiPath);
  344. const custom = JSON.parse(readFileSync(customTuiPath, 'utf-8'));
  345. const original = JSON.parse(readFileSync(defaultTuiPath, 'utf-8'));
  346. expect(custom.plugin).toEqual(['oh-my-opencode-slim']);
  347. expect(original.plugin).toEqual(['default']);
  348. });
  349. test('addPluginToOpenCodeTuiConfig stores local repo path for local dev paths', async () => {
  350. const tuiPath = join(tmpDir, 'opencode', 'tui.json');
  351. const packageRoot = join(tmpDir, 'repo');
  352. const localCliPath = join(packageRoot, 'dist', 'cli', 'index.js');
  353. paths.ensureConfigDir();
  354. writeFileSync(tuiPath, JSON.stringify({ plugin: [] }));
  355. writePackageJson(packageRoot);
  356. process.argv[1] = localCliPath;
  357. const result = await addPluginToOpenCodeTuiConfig();
  358. expect(result.success).toBe(true);
  359. const saved = JSON.parse(readFileSync(tuiPath, 'utf-8'));
  360. expect(saved.plugin).toEqual([packageRoot]);
  361. });
  362. test('addPluginToOpenCodeTuiConfig deduplicates existing local repo path entries', async () => {
  363. const tuiPath = join(tmpDir, 'opencode', 'tui.json');
  364. const packageRoot = join(tmpDir, 'repo');
  365. const localCliPath = join(packageRoot, 'dist', 'cli', 'index.js');
  366. paths.ensureConfigDir();
  367. writePackageJson(packageRoot);
  368. writeFileSync(tuiPath, JSON.stringify({ plugin: ['other', packageRoot] }));
  369. process.argv[1] = localCliPath;
  370. const result = await addPluginToOpenCodeTuiConfig();
  371. expect(result.success).toBe(true);
  372. const saved = JSON.parse(readFileSync(tuiPath, 'utf-8'));
  373. expect(saved.plugin).toEqual(['other', packageRoot]);
  374. });
  375. test('addPluginToOpenCodeTuiConfig preserves non-string plugin entries when refreshing', async () => {
  376. const tuiPath = join(tmpDir, 'opencode', 'tui.json');
  377. paths.ensureConfigDir();
  378. process.argv[1] = '';
  379. const objectPlugin = { name: 'some-tui-plugin', enabled: true };
  380. writeFileSync(
  381. tuiPath,
  382. JSON.stringify({
  383. plugin: ['other-plugin', objectPlugin, 'oh-my-opencode-slim@1.0.0'],
  384. }),
  385. );
  386. const result = await addPluginToOpenCodeTuiConfig();
  387. expect(result.success).toBe(true);
  388. const saved = JSON.parse(readFileSync(tuiPath, 'utf-8'));
  389. expect(saved.plugin).toContain('oh-my-opencode-slim');
  390. expect(saved.plugin).toContain('other-plugin');
  391. expect(saved.plugin).not.toContain('oh-my-opencode-slim@1.0.0');
  392. // Non-string entries (objects) must survive the plugin refresh
  393. expect(saved.plugin).toContainEqual(objectPlugin);
  394. expect(saved.plugin.length).toBe(3);
  395. });
  396. test('writeLiteConfig writes lite config with OpenAI preset', () => {
  397. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  398. paths.ensureConfigDir();
  399. const result = writeLiteConfig({
  400. installCustomSkills: false,
  401. reset: false,
  402. });
  403. expect(result.success).toBe(true);
  404. const saved = JSON.parse(readFileSync(litePath, 'utf-8'));
  405. expect(saved.$schema).toBe(
  406. 'https://unpkg.com/oh-my-opencode-slim@latest/oh-my-opencode-slim.schema.json',
  407. );
  408. expect(saved.preset).toBe('openai');
  409. expect(saved.presets.openai).toBeDefined();
  410. expect(saved.presets['opencode-go']).toBeDefined();
  411. });
  412. test('writeLiteConfig writes selected preset', () => {
  413. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  414. paths.ensureConfigDir();
  415. const result = writeLiteConfig({
  416. installCustomSkills: false,
  417. preset: 'opencode-go',
  418. reset: false,
  419. });
  420. expect(result.success).toBe(true);
  421. const saved = JSON.parse(readFileSync(litePath, 'utf-8'));
  422. expect(saved.preset).toBe('opencode-go');
  423. expect(saved.disabled_agents).toEqual([]);
  424. expect(saved.presets.openai).toBeDefined();
  425. expect(saved.presets['opencode-go'].orchestrator.model).toBe(
  426. 'opencode-go/minimax-m3',
  427. );
  428. expect(saved.presets['opencode-go'].orchestrator.variant).toBe('thinking');
  429. expect(saved.presets['opencode-go'].observer.model).toBe(
  430. 'opencode-go/mimo-v2.5',
  431. );
  432. expect(saved.presets['opencode-go'].observer.variant).toBeUndefined();
  433. });
  434. test('disableDefaultAgents disables conflicting OpenCode built-in agents', () => {
  435. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  436. paths.ensureConfigDir();
  437. writeFileSync(configPath, JSON.stringify({}));
  438. const result = disableDefaultAgents();
  439. expect(result.success).toBe(true);
  440. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  441. expect(saved.agent.explore.disable).toBe(true);
  442. expect(saved.agent.general.disable).toBe(true);
  443. expect(saved.agent.build).toBeUndefined();
  444. expect(saved.agent.plan).toBeUndefined();
  445. });
  446. test('disableDefaultAgents preserves existing build and plan agent config', () => {
  447. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  448. paths.ensureConfigDir();
  449. writeFileSync(
  450. configPath,
  451. JSON.stringify({
  452. agent: {
  453. build: { description: 'custom build agent' },
  454. plan: { permission: { edit: 'deny' } },
  455. },
  456. }),
  457. );
  458. const result = disableDefaultAgents();
  459. expect(result.success).toBe(true);
  460. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  461. expect(saved.agent.build).toEqual({ description: 'custom build agent' });
  462. expect(saved.agent.plan).toEqual({ permission: { edit: 'deny' } });
  463. expect(saved.agent.explore.disable).toBe(true);
  464. expect(saved.agent.general.disable).toBe(true);
  465. });
  466. test('enableLspByDefault sets lsp true when missing', () => {
  467. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  468. paths.ensureConfigDir();
  469. writeFileSync(configPath, JSON.stringify({ plugin: ['other'] }));
  470. const result = enableLspByDefault();
  471. expect(result.success).toBe(true);
  472. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  473. expect(saved.lsp).toBe(true);
  474. expect(saved.plugin).toEqual(['other']);
  475. });
  476. test('enableLspByDefault preserves explicit lsp config', () => {
  477. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  478. paths.ensureConfigDir();
  479. writeFileSync(configPath, JSON.stringify({ lsp: false }));
  480. const result = enableLspByDefault();
  481. expect(result.success).toBe(true);
  482. const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
  483. expect(saved.lsp).toBe(false);
  484. });
  485. test('enableLspByDefault does not write when lsp exists', () => {
  486. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  487. paths.ensureConfigDir();
  488. writeFileSync(configPath, JSON.stringify({ lsp: false }));
  489. const result = enableLspByDefault();
  490. expect(result.success).toBe(true);
  491. expect(existsSync(`${configPath}.bak`)).toBe(false);
  492. });
  493. test('detectCurrentConfig detects installed status', () => {
  494. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  495. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  496. paths.ensureConfigDir();
  497. writeFileSync(
  498. configPath,
  499. JSON.stringify({
  500. plugin: ['oh-my-opencode-slim'],
  501. provider: {
  502. kimi: {
  503. npm: '@ai-sdk/openai-compatible',
  504. },
  505. },
  506. }),
  507. );
  508. writeFileSync(
  509. litePath,
  510. JSON.stringify({
  511. preset: 'openai',
  512. presets: {
  513. openai: {
  514. orchestrator: { model: 'openai/gpt-4' },
  515. oracle: { model: 'anthropic/claude-opus-4-6' },
  516. explorer: { model: 'github-copilot/grok-code-fast-1' },
  517. librarian: { model: 'zai-coding-plan/glm-4.7' },
  518. },
  519. },
  520. }),
  521. );
  522. const detected = detectCurrentConfig();
  523. expect(detected.isInstalled).toBe(true);
  524. expect(detected.hasKimi).toBe(true);
  525. expect(detected.hasOpenAI).toBe(true);
  526. expect(detected.hasAnthropic).toBe(true);
  527. expect(detected.hasCopilot).toBe(true);
  528. expect(detected.hasZaiPlan).toBe(true);
  529. });
  530. test('detectCurrentConfig detects installed status for installer-managed tuple', () => {
  531. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  532. paths.ensureConfigDir();
  533. writeFileSync(
  534. configPath,
  535. JSON.stringify({
  536. plugin: [
  537. [
  538. 'oh-my-opencode-slim@1.2.3',
  539. { __ohMyOpencodeSlimManagedByInstaller: true },
  540. ],
  541. ],
  542. }),
  543. );
  544. const detected = detectCurrentConfig();
  545. expect(detected.isInstalled).toBe(true);
  546. });
  547. test('detectCurrentConfig detects provider models in arrays', () => {
  548. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  549. const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
  550. paths.ensureConfigDir();
  551. writeFileSync(
  552. configPath,
  553. JSON.stringify({ plugin: ['oh-my-opencode-slim'] }),
  554. );
  555. writeFileSync(
  556. litePath,
  557. JSON.stringify({
  558. preset: 'dev',
  559. presets: {
  560. dev: {
  561. orchestrator: {
  562. model: [
  563. 'openai/gpt-5.6-luna',
  564. { id: 'anthropic/claude-opus-4-6' },
  565. ],
  566. },
  567. },
  568. },
  569. }),
  570. );
  571. const detected = detectCurrentConfig();
  572. expect(detected.hasOpenAI).toBe(true);
  573. expect(detected.hasAnthropic).toBe(true);
  574. });
  575. test('detectCurrentConfig treats local repo path entries as installed', () => {
  576. const configPath = join(tmpDir, 'opencode', 'opencode.json');
  577. const packageRoot = join(tmpDir, 'repo');
  578. paths.ensureConfigDir();
  579. writePackageJson(packageRoot);
  580. writeFileSync(configPath, JSON.stringify({ plugin: [packageRoot] }));
  581. const detected = detectCurrentConfig();
  582. expect(detected.isInstalled).toBe(true);
  583. });
  584. });