| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656 |
- import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
- import * as fs from 'node:fs';
- import * as os from 'node:os';
- import * as path from 'node:path';
- import type { ConfigLoadWarning } from './loader';
- import { loadAgentPrompt, loadPluginConfig } from './loader';
- // Test deepMerge indirectly through loadPluginConfig behavior
- // since deepMerge is not exported
- describe('loadPluginConfig', () => {
- let tempDir: string;
- let userConfigDir: string;
- let originalEnv: typeof process.env;
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loader-test-'));
- userConfigDir = path.join(tempDir, 'user-config');
- originalEnv = { ...process.env };
- // Isolate from real user config
- delete process.env.OPENCODE_CONFIG_DIR;
- process.env.XDG_CONFIG_HOME = userConfigDir;
- });
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- process.env = originalEnv;
- });
- test('returns empty config when no config files exist', () => {
- const projectDir = path.join(tempDir, 'project');
- fs.mkdirSync(projectDir, { recursive: true });
- const config = loadPluginConfig(projectDir);
- expect(config).toEqual({});
- });
- test('loads project config from .opencode directory', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: {
- oracle: { model: 'test/model' },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('test/model');
- });
- test('loads scoringEngineVersion flag when configured', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- scoringEngineVersion: 'v2-shadow',
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.scoringEngineVersion).toBe('v2-shadow');
- });
- test('loads balanceProviderUsage flag when configured', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- balanceProviderUsage: true,
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.balanceProviderUsage).toBe(true);
- });
- test('loads autoUpdate flag when configured', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- autoUpdate: false,
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.autoUpdate).toBe(false);
- });
- test('loads manual plan structure when configured', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- manualPlan: {
- orchestrator: {
- primary: 'openai/gpt-5.5',
- fallback1: 'anthropic/claude-opus-4-6',
- fallback2: 'chutes/kimi-k2.5',
- fallback3: 'opencode/gpt-5-nano',
- },
- oracle: {
- primary: 'openai/gpt-5.5',
- fallback1: 'anthropic/claude-opus-4-6',
- fallback2: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
- fallback3: 'opencode/gpt-5-nano',
- },
- designer: {
- primary: 'openai/gpt-5.5',
- fallback1: 'anthropic/claude-opus-4-6',
- fallback2: 'chutes/kimi-k2.5',
- fallback3: 'opencode/gpt-5-nano',
- },
- explorer: {
- primary: 'openai/gpt-5.5',
- fallback1: 'anthropic/claude-opus-4-6',
- fallback2: 'chutes/kimi-k2.5',
- fallback3: 'opencode/gpt-5-nano',
- },
- librarian: {
- primary: 'openai/gpt-5.5',
- fallback1: 'anthropic/claude-opus-4-6',
- fallback2: 'chutes/kimi-k2.5',
- fallback3: 'opencode/gpt-5-nano',
- },
- fixer: {
- primary: 'openai/gpt-5.5',
- fallback1: 'anthropic/claude-opus-4-6',
- fallback2: 'chutes/kimi-k2.5',
- fallback3: 'opencode/gpt-5-nano',
- },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.manualPlan?.oracle?.fallback2).toBe(
- 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
- );
- });
- test('ignores invalid config (schema violation or malformed JSON)', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- // Test 1: Invalid temperature (out of range)
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
- );
- expect(loadPluginConfig(projectDir)).toEqual({});
- // Test 2: Malformed JSON
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- '{ invalid json }',
- );
- expect(loadPluginConfig(projectDir)).toEqual({});
- });
- test('rejects custom-only prompt fields on built-in agents in config files', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: {
- oracle: {
- model: 'openai/gpt-5.5',
- prompt: 'This should be rejected for built-in agents.',
- },
- },
- }),
- );
- expect(loadPluginConfig(projectDir)).toEqual({});
- });
- test('respects OPENCODE_CONFIG_DIR for user config location', () => {
- const customDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'omc-opencode-config-'),
- );
- process.env.OPENCODE_CONFIG_DIR = customDir;
- // Write plugin config in the custom directory
- fs.writeFileSync(
- path.join(customDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: { oracle: { model: 'custom/model-from-opencode-config-dir' } },
- }),
- );
- const projectDir = path.join(tempDir, 'project');
- fs.mkdirSync(projectDir, { recursive: true });
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe(
- 'custom/model-from-opencode-config-dir',
- );
- fs.rmSync(customDir, { recursive: true, force: true });
- });
- test('falls back to default user config dir when OPENCODE_CONFIG_DIR has no config', () => {
- const customDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'omc-opencode-config-empty-'),
- );
- process.env.OPENCODE_CONFIG_DIR = customDir;
- const defaultConfigDir = path.join(userConfigDir, 'opencode');
- fs.mkdirSync(defaultConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(defaultConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: { oracle: { model: 'fallback/default-config' } },
- }),
- );
- const projectDir = path.join(tempDir, 'project');
- fs.mkdirSync(projectDir, { recursive: true });
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('fallback/default-config');
- fs.rmSync(customDir, { recursive: true, force: true });
- });
- });
- describe('onWarning callback', () => {
- let tempDir: string;
- let originalEnv: typeof process.env;
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onwarning-test-'));
- originalEnv = { ...process.env };
- delete process.env.OPENCODE_CONFIG_DIR;
- process.env.XDG_CONFIG_HOME = tempDir;
- });
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- process.env = originalEnv;
- });
- test('invalid schema calls onWarning with invalid-schema', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
- );
- const warnings: ConfigLoadWarning[] = [];
- const config = loadPluginConfig(projectDir, {
- onWarning: (warning) => warnings.push(warning),
- });
- expect(warnings).toHaveLength(1);
- expect(warnings[0]?.kind).toBe('invalid-schema');
- expect(warnings[0]?.path).toBe(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- );
- expect(warnings[0]?.message).toBe('Config does not match schema');
- expect(config).toEqual({});
- });
- test('invalid JSON calls onWarning with invalid-json', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- '{ invalid json }',
- );
- const warnings: ConfigLoadWarning[] = [];
- const config = loadPluginConfig(projectDir, {
- onWarning: (warning) => warnings.push(warning),
- });
- expect(warnings).toHaveLength(1);
- expect(warnings[0]?.kind).toBe('invalid-json');
- expect(warnings[0]?.path).toBe(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- );
- expect(config).toEqual({});
- });
- test('silent option suppresses console warnings', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- '{ invalid json }',
- );
- const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
- try {
- const warnings: ConfigLoadWarning[] = [];
- const config = loadPluginConfig(projectDir, {
- silent: true,
- onWarning: (warning) => warnings.push(warning),
- });
- expect(warnings).toHaveLength(1);
- expect(config).toEqual({});
- expect(warnSpy).not.toHaveBeenCalled();
- } finally {
- warnSpy.mockRestore();
- }
- });
- test('read error calls onWarning with read-error', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- const configPath = path.join(projectConfigDir, 'oh-my-opencode-slim.json');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(configPath, JSON.stringify({}));
- const originalReadFileSync = fs.readFileSync;
- const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
- ...args: Parameters<typeof fs.readFileSync>
- ) => {
- const [filePath] = args;
- if (filePath === configPath) {
- const error = new Error('Permission denied') as NodeJS.ErrnoException;
- error.code = 'EACCES';
- throw error;
- }
- return originalReadFileSync(...args);
- }) as typeof fs.readFileSync);
- try {
- const warnings: ConfigLoadWarning[] = [];
- const config = loadPluginConfig(projectDir, {
- onWarning: (warning) => warnings.push(warning),
- });
- expect(warnings).toHaveLength(1);
- expect(warnings[0]?.kind).toBe('read-error');
- expect(warnings[0]?.path).toBe(configPath);
- expect(warnings[0]?.message).toBe('Permission denied');
- expect(config).toEqual({});
- } finally {
- readSpy.mockRestore();
- }
- });
- test('missing preset calls onWarning with missing-preset', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'nonexistent',
- presets: { other: { oracle: { model: 'other' } } },
- agents: { oracle: { model: 'root' } },
- }),
- );
- const warnings: ConfigLoadWarning[] = [];
- const config = loadPluginConfig(projectDir, {
- onWarning: (warning) => warnings.push(warning),
- });
- expect(warnings).toHaveLength(1);
- expect(warnings[0]?.kind).toBe('missing-preset');
- expect(warnings[0]?.message).toContain('Preset "nonexistent" not found');
- expect(config.agents?.oracle?.model).toBe('root');
- });
- test('silent: true on missing preset still calls onWarning but not console.warn', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'nonexistent',
- presets: { other: { oracle: { model: 'other' } } },
- agents: { oracle: { model: 'root' } },
- }),
- );
- const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
- try {
- const warnings: ConfigLoadWarning[] = [];
- const config = loadPluginConfig(projectDir, {
- silent: true,
- onWarning: (warning) => warnings.push(warning),
- });
- expect(warnings).toHaveLength(1);
- expect(warnings[0]?.kind).toBe('missing-preset');
- expect(config.agents?.oracle?.model).toBe('root');
- expect(warnSpy).not.toHaveBeenCalled();
- } finally {
- warnSpy.mockRestore();
- }
- });
- test('valid config does not call onWarning', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({ agents: { oracle: { model: 'valid/model' } } }),
- );
- const warnings: ConfigLoadWarning[] = [];
- const config = loadPluginConfig(projectDir, {
- onWarning: (warning) => warnings.push(warning),
- });
- expect(warnings).toHaveLength(0);
- expect(config.agents?.oracle?.model).toBe('valid/model');
- });
- test('no options object does not break loadPluginConfig', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({ agents: { oracle: { model: 'model' } } }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('model');
- });
- });
- describe('deepMerge behavior', () => {
- let tempDir: string;
- let userConfigDir: string;
- let originalEnv: typeof process.env;
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-test-'));
- userConfigDir = path.join(tempDir, 'user-config');
- originalEnv = { ...process.env };
- // Set XDG_CONFIG_HOME to control user config location
- delete process.env.OPENCODE_CONFIG_DIR;
- process.env.XDG_CONFIG_HOME = userConfigDir;
- });
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- process.env = originalEnv;
- });
- test('merges nested agent configs from user and project', () => {
- // Create user config
- const userOpencodeDir = path.join(userConfigDir, 'opencode');
- fs.mkdirSync(userOpencodeDir, { recursive: true });
- fs.writeFileSync(
- path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: {
- oracle: { model: 'user/oracle-model', temperature: 0.5 },
- explorer: { model: 'user/explorer-model' },
- },
- }),
- );
- // Create project config (should override/merge with user)
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: {
- oracle: { temperature: 0.8 }, // Override temperature only
- designer: { model: 'project/designer-model' }, // Add new agent
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- // oracle: model from user, temperature from project
- expect(config.agents?.oracle?.model).toBe('user/oracle-model');
- expect(config.agents?.oracle?.temperature).toBe(0.8);
- // explorer: from user only
- expect(config.agents?.explorer?.model).toBe('user/explorer-model');
- // designer: from project only
- expect(config.agents?.designer?.model).toBe('project/designer-model');
- });
- test('merges nested tmux configs', () => {
- const userOpencodeDir = path.join(userConfigDir, 'opencode');
- fs.mkdirSync(userOpencodeDir, { recursive: true });
- fs.writeFileSync(
- path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- tmux: {
- enabled: true,
- layout: 'main-vertical',
- main_pane_size: 60,
- },
- }),
- );
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- tmux: {
- enabled: false, // Override enabled
- layout: 'tiled', // Override layout
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.tmux?.enabled).toBe(false); // From project (override)
- expect(config.tmux?.layout).toBe('tiled'); // From project
- expect(config.tmux?.main_pane_size).toBe(60); // From user (preserved)
- });
- test("preserves user tmux.enabled when project doesn't specify", () => {
- const userOpencodeDir = path.join(userConfigDir, 'opencode');
- fs.mkdirSync(userOpencodeDir, { recursive: true });
- fs.writeFileSync(
- path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- tmux: {
- enabled: true,
- layout: 'main-vertical',
- },
- }),
- );
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: { oracle: { model: 'test' } }, // No tmux override
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.tmux?.enabled).toBe(true); // Preserved from user
- expect(config.tmux?.layout).toBe('main-vertical'); // Preserved from user
- });
- test('project config overrides top-level arrays', () => {
- const userOpencodeDir = path.join(userConfigDir, 'opencode');
- fs.mkdirSync(userOpencodeDir, { recursive: true });
- fs.writeFileSync(
- path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- disabled_mcps: ['websearch'],
- }),
- );
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- disabled_mcps: ['context7'],
- }),
- );
- const config = loadPluginConfig(projectDir);
- // disabled_mcps should be from project (overwrites, not merges)
- expect(config.disabled_mcps).toEqual(['context7']);
- });
- test('handles missing user config gracefully', () => {
- // Don't create user config, only project
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: {
- oracle: { model: 'project/model' },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('project/model');
- });
- test('handles missing project config gracefully', () => {
- const userOpencodeDir = path.join(userConfigDir, 'opencode');
- fs.mkdirSync(userOpencodeDir, { recursive: true });
- fs.writeFileSync(
- path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: {
- oracle: { model: 'user/model' },
- },
- }),
- );
- // No project config
- const projectDir = path.join(tempDir, 'project');
- fs.mkdirSync(projectDir, { recursive: true });
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('user/model');
- });
- test('merges fallback timeout and chains from user and project', () => {
- const userOpencodeDir = path.join(userConfigDir, 'opencode');
- fs.mkdirSync(userOpencodeDir, { recursive: true });
- fs.writeFileSync(
- path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- fallback: {
- timeoutMs: 15000,
- chains: {
- oracle: ['openai/gpt-5.5', 'opencode/glm-4.7-free'],
- },
- },
- }),
- );
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- fallback: {
- chains: {
- explorer: ['google/antigravity-gemini-3-flash'],
- },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.fallback?.timeoutMs).toBe(15000);
- expect(config.fallback?.chains.oracle).toEqual([
- 'openai/gpt-5.5',
- 'opencode/glm-4.7-free',
- ]);
- expect(config.fallback?.chains.explorer).toEqual([
- 'google/antigravity-gemini-3-flash',
- ]);
- });
- test('preserves fallback chains with additional agent keys', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- fallback: {
- chains: {
- writing: ['openai/gpt-5.5'],
- },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.fallback?.chains.writing).toEqual(['openai/gpt-5.5']);
- });
- });
- describe('preset resolution', () => {
- let tempDir: string;
- let originalEnv: typeof process.env;
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-test-'));
- originalEnv = { ...process.env };
- delete process.env.OPENCODE_CONFIG_DIR;
- process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
- });
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- process.env = originalEnv;
- });
- test('backward compatibility: config with only agents works unchanged', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: { oracle: { model: 'direct-model' } },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('direct-model');
- expect(config.preset).toBeUndefined();
- });
- test("preset applied: preset + presets returns preset's agents", () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'fast',
- presets: {
- fast: { oracle: { model: 'fast-model' } },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('fast-model');
- });
- test('root agents override preset agents', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'fast',
- presets: {
- fast: {
- oracle: { model: 'fast-model', temperature: 0.1 },
- explorer: { model: 'explorer-model' },
- },
- },
- agents: {
- oracle: { temperature: 0.9 }, // Should override preset temperature
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('fast-model');
- expect(config.agents?.oracle?.temperature).toBe(0.9);
- expect(config.agents?.explorer?.model).toBe('explorer-model');
- });
- test('missing preset: preset set but not in presets -> returns empty/root agents', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'nonexistent',
- presets: {
- other: { oracle: { model: 'other' } },
- },
- agents: { oracle: { model: 'root' } },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('root');
- });
- test('preset only: no root agents, just preset works', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'dev',
- presets: {
- dev: { oracle: { model: 'dev-model' } },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('dev-model');
- });
- test('invalid preset shape: bad agent config in preset fails schema validation', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- // preset agents with invalid temperature
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'invalid',
- presets: {
- invalid: { oracle: { temperature: 5 } },
- },
- }),
- );
- // Should return empty config due to validation failure
- expect(loadPluginConfig(projectDir)).toEqual({});
- });
- test('nonexistent preset from config warns and falls back to root agents', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'nonexistent',
- presets: {
- other: { oracle: { model: 'other' } },
- },
- agents: { oracle: { model: 'root' } },
- }),
- );
- const consoleWarnSpy = spyOn(console, 'warn');
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('root');
- expect(consoleWarnSpy).toHaveBeenCalled();
- const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
- expect(warningMessage).toContain('Preset "nonexistent" not found');
- expect(warningMessage).toContain('Available presets: other');
- });
- test('nonexistent preset with no root agents returns empty agents', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'nonexistent',
- presets: {
- other: { oracle: { model: 'other' } },
- },
- }),
- );
- const consoleWarnSpy = spyOn(console, 'warn');
- const config = loadPluginConfig(projectDir);
- expect(config.agents).toBeUndefined();
- expect(consoleWarnSpy).toHaveBeenCalled();
- const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
- expect(warningMessage).toContain('Preset "nonexistent" not found');
- });
- test('options from preset are deep-merged with root agents', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'openai',
- presets: {
- openai: {
- oracle: {
- model: 'openai/gpt-5.5',
- options: { textVerbosity: 'low' },
- },
- },
- },
- agents: {
- oracle: {
- options: { reasoningEffort: 'medium' },
- },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('openai/gpt-5.5');
- // deepMerge should combine both option keys
- expect(config.agents?.oracle?.options).toEqual({
- textVerbosity: 'low',
- reasoningEffort: 'medium',
- });
- });
- test('options from preset only work without root agents', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'anthropic-thinking',
- presets: {
- 'anthropic-thinking': {
- oracle: {
- model: 'anthropic/claude-sonnet-4-6',
- options: {
- thinking: { type: 'enabled', budgetTokens: 16000 },
- },
- },
- },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('anthropic/claude-sonnet-4-6');
- expect(config.agents?.oracle?.options).toEqual({
- thinking: { type: 'enabled', budgetTokens: 16000 },
- });
- });
- test('root options override preset options for same key', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'concise',
- presets: {
- concise: {
- oracle: {
- model: 'openai/gpt-5.5',
- options: { textVerbosity: 'low' },
- },
- },
- },
- agents: {
- oracle: {
- options: { textVerbosity: 'high' },
- },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('openai/gpt-5.5');
- // root wins over preset for same key
- expect(config.agents?.oracle?.options).toEqual({
- textVerbosity: 'high',
- });
- });
- });
- describe('environment variable preset override', () => {
- let tempDir: string;
- let originalEnv: typeof process.env;
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-preset-test-'));
- originalEnv = { ...process.env };
- delete process.env.OPENCODE_CONFIG_DIR;
- process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
- });
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- process.env = originalEnv;
- });
- test('Env var overrides preset from config file', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'config-preset',
- presets: {
- 'config-preset': { oracle: { model: 'config-model' } },
- 'env-preset': { oracle: { model: 'env-model' } },
- },
- }),
- );
- process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
- const config = loadPluginConfig(projectDir);
- expect(config.preset).toBe('env-preset');
- expect(config.agents?.oracle?.model).toBe('env-model');
- });
- test('Env var works when config has no preset', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- presets: {
- 'env-preset': { oracle: { model: 'env-model' } },
- },
- }),
- );
- process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
- const config = loadPluginConfig(projectDir);
- expect(config.preset).toBe('env-preset');
- expect(config.agents?.oracle?.model).toBe('env-model');
- });
- test('Env var is ignored if empty string', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'config-preset',
- presets: {
- 'config-preset': { oracle: { model: 'config-model' } },
- },
- }),
- );
- process.env.OH_MY_OPENCODE_SLIM_PRESET = '';
- const config = loadPluginConfig(projectDir);
- expect(config.preset).toBe('config-preset');
- expect(config.agents?.oracle?.model).toBe('config-model');
- });
- test('Env var is ignored if undefined', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'config-preset',
- presets: {
- 'config-preset': { oracle: { model: 'config-model' } },
- },
- }),
- );
- delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
- const config = loadPluginConfig(projectDir);
- expect(config.preset).toBe('config-preset');
- expect(config.agents?.oracle?.model).toBe('config-model');
- });
- test('Env var with nonexistent preset warns and falls back', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- preset: 'config-preset',
- presets: {
- 'config-preset': { oracle: { model: 'config-model' } },
- },
- agents: { oracle: { model: 'fallback' } },
- }),
- );
- process.env.OH_MY_OPENCODE_SLIM_PRESET = 'typo-preset';
- const consoleWarnSpy = spyOn(console, 'warn');
- const config = loadPluginConfig(projectDir);
- expect(config.preset).toBe('typo-preset');
- expect(config.agents?.oracle?.model).toBe('fallback');
- expect(consoleWarnSpy).toHaveBeenCalled();
- const calls = consoleWarnSpy.mock.calls as string[][];
- const warningMessage =
- calls.find((call) => call[0]?.includes('typo-preset'))?.[0] || '';
- expect(warningMessage).toContain('Preset "typo-preset" not found');
- expect(warningMessage).toContain('environment variable');
- expect(warningMessage).toContain('config-preset');
- });
- });
- describe('JSONC config support', () => {
- let tempDir: string;
- let originalEnv: typeof process.env;
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonc-test-'));
- originalEnv = { ...process.env };
- delete process.env.OPENCODE_CONFIG_DIR;
- process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
- });
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- process.env = originalEnv;
- });
- test('loads .jsonc file with single-line comments', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
- `{
- // This is a comment
- "agents": {
- "oracle": { "model": "test/model" } // inline comment
- }
- }`,
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('test/model');
- });
- test('loads .jsonc file with multi-line comments', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
- `{
- /* Multi-line
- comment block */
- "agents": {
- "explorer": { "model": "explorer-model" }
- }
- }`,
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.explorer?.model).toBe('explorer-model');
- });
- test('loads .jsonc file with trailing commas', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
- `{
- "agents": {
- "oracle": { "model": "test-model", },
- },
- }`,
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('test-model');
- });
- test('prefers .jsonc over .json when both exist', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- // Create both files
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
- );
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
- `{
- // JSONC version
- "agents": { "oracle": { "model": "jsonc-model" } }
- }`,
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('jsonc-model');
- });
- test('falls back to .json when .jsonc does not exist', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- // Only create .json file
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('json-model');
- });
- test('loads user config from .jsonc', () => {
- const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
- fs.mkdirSync(userOpencodeDir, { recursive: true });
- fs.writeFileSync(
- path.join(userOpencodeDir, 'oh-my-opencode-slim.jsonc'),
- `{
- // User config with comments
- "agents": { "librarian": { "model": "user-librarian" } }
- }`,
- );
- const projectDir = path.join(tempDir, 'project');
- fs.mkdirSync(projectDir, { recursive: true });
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.librarian?.model).toBe('user-librarian');
- });
- test('merges user .jsonc with project .jsonc', () => {
- const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
- fs.mkdirSync(userOpencodeDir, { recursive: true });
- fs.writeFileSync(
- path.join(userOpencodeDir, 'oh-my-opencode-slim.jsonc'),
- `{
- // User config
- "agents": {
- "oracle": { "model": "user-oracle", "temperature": 0.5 }
- }
- }`,
- );
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
- `{
- // Project config
- "agents": { "oracle": { "temperature": 0.8 } }
- }`,
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('user-oracle');
- expect(config.agents?.oracle?.temperature).toBe(0.8);
- });
- test('handles complex JSONC with mixed comments and trailing commas', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
- `{
- // Main configuration
- "preset": "dev",
- /* Presets definition */
- "presets": {
- "dev": {
- // Development agents
- "oracle": { "model": "dev-oracle", },
- "explorer": { "model": "dev-explorer", },
- },
- },
- "tmux": {
- "enabled": true, // Enable tmux
- "layout": "main-vertical",
- },
- }`,
- );
- const config = loadPluginConfig(projectDir);
- expect(config.preset).toBe('dev');
- expect(config.agents?.oracle?.model).toBe('dev-oracle');
- expect(config.agents?.explorer?.model).toBe('dev-explorer');
- expect(config.tmux?.enabled).toBe(true);
- expect(config.tmux?.layout).toBe('main-vertical');
- });
- });
- describe('loadAgentPrompt', () => {
- let tempDir: string;
- let originalEnv: typeof process.env;
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-test-'));
- originalEnv = { ...process.env };
- delete process.env.OPENCODE_CONFIG_DIR;
- process.env.XDG_CONFIG_HOME = tempDir;
- });
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- process.env = originalEnv;
- });
- test('returns empty object when no prompt files exist', () => {
- const result = loadAgentPrompt('oracle');
- expect(result).toEqual({});
- });
- test('loads replacement prompt from {agent}.md', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- fs.mkdirSync(promptsDir, { recursive: true });
- fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
- const result = loadAgentPrompt('oracle');
- expect(result.prompt).toBe('replacement prompt');
- expect(result.appendPrompt).toBeUndefined();
- });
- test('loads append prompt from {agent}_append.md', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- fs.mkdirSync(promptsDir, { recursive: true });
- fs.writeFileSync(
- path.join(promptsDir, 'oracle_append.md'),
- 'append prompt',
- );
- const result = loadAgentPrompt('oracle');
- expect(result.prompt).toBeUndefined();
- expect(result.appendPrompt).toBe('append prompt');
- });
- test('loads both replacement and append prompts', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- fs.mkdirSync(promptsDir, { recursive: true });
- fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
- fs.writeFileSync(
- path.join(promptsDir, 'oracle_append.md'),
- 'append prompt',
- );
- const result = loadAgentPrompt('oracle');
- expect(result.prompt).toBe('replacement prompt');
- expect(result.appendPrompt).toBe('append prompt');
- });
- test('handles file read errors gracefully', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- fs.mkdirSync(promptsDir, { recursive: true });
- const promptPath = path.join(promptsDir, 'error-agent.md');
- fs.writeFileSync(promptPath, 'content');
- const consoleWarnSpy = spyOn(console, 'warn');
- // Use a unique agent name and check for it specifically
- const originalReadFileSync = fs.readFileSync;
- const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
- ...args: Parameters<typeof fs.readFileSync>
- ) => {
- const [p] = args;
- if (typeof p === 'string' && p.includes('error-agent.md')) {
- throw new Error('Read error');
- }
- return originalReadFileSync(...args);
- }) as typeof fs.readFileSync);
- try {
- const result = loadAgentPrompt('error-agent');
- expect(result.prompt).toBeUndefined();
- const warningFound = consoleWarnSpy.mock.calls.some((call) =>
- (call[0] as string).includes('Error reading prompt file'),
- );
- expect(warningFound).toBe(true);
- } finally {
- readSpy.mockRestore();
- }
- });
- test('prefers preset prompt files over root prompts', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- const presetDir = path.join(promptsDir, 'test');
- fs.mkdirSync(presetDir, { recursive: true });
- fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
- fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
- fs.writeFileSync(
- path.join(promptsDir, 'oracle_append.md'),
- 'root append prompt',
- );
- fs.writeFileSync(
- path.join(presetDir, 'oracle_append.md'),
- 'preset append prompt',
- );
- const result = loadAgentPrompt('oracle', 'test');
- expect(result.prompt).toBe('preset replacement');
- expect(result.appendPrompt).toBe('preset append prompt');
- });
- test('falls back to root prompt files when preset files are missing', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- const presetDir = path.join(promptsDir, 'test');
- fs.mkdirSync(presetDir, { recursive: true });
- fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
- fs.writeFileSync(
- path.join(promptsDir, 'oracle_append.md'),
- 'root append prompt',
- );
- const result = loadAgentPrompt('oracle', 'test');
- expect(result.prompt).toBe('root replacement');
- expect(result.appendPrompt).toBe('root append prompt');
- });
- test('falls back independently between preset and root files', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- const presetDir = path.join(promptsDir, 'test');
- fs.mkdirSync(presetDir, { recursive: true });
- fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
- fs.writeFileSync(
- path.join(promptsDir, 'oracle_append.md'),
- 'root append prompt',
- );
- const result = loadAgentPrompt('oracle', 'test');
- expect(result.prompt).toBe('preset replacement');
- expect(result.appendPrompt).toBe('root append prompt');
- });
- test('ignores unsafe preset names for prompt lookup', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- fs.mkdirSync(promptsDir, { recursive: true });
- fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
- const result = loadAgentPrompt('oracle', '../test');
- expect(result.prompt).toBe('root replacement');
- expect(result.appendPrompt).toBeUndefined();
- });
- test('falls back to root when preset prompt file read fails', () => {
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- const presetDir = path.join(promptsDir, 'test');
- fs.mkdirSync(presetDir, { recursive: true });
- const presetPromptPath = path.join(presetDir, 'oracle.md');
- fs.writeFileSync(presetPromptPath, 'preset replacement');
- fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
- const consoleWarnSpy = spyOn(console, 'warn');
- const originalReadFileSync = fs.readFileSync;
- const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
- ...args: Parameters<typeof fs.readFileSync>
- ) => {
- const [p] = args;
- if (typeof p === 'string' && p === presetPromptPath) {
- throw new Error('Preset read error');
- }
- return originalReadFileSync(...args);
- }) as typeof fs.readFileSync);
- try {
- const result = loadAgentPrompt('oracle', 'test');
- expect(result.prompt).toBe('root replacement');
- expect(consoleWarnSpy).toHaveBeenCalled();
- } finally {
- readSpy.mockRestore();
- }
- });
- test('works with XDG_CONFIG_HOME environment variable', () => {
- const customConfigHome = path.join(tempDir, 'custom-xdg');
- process.env.XDG_CONFIG_HOME = customConfigHome;
- const promptsDir = path.join(
- customConfigHome,
- 'opencode',
- 'oh-my-opencode-slim',
- );
- fs.mkdirSync(promptsDir, { recursive: true });
- fs.writeFileSync(path.join(promptsDir, 'xdg-agent.md'), 'xdg prompt');
- const result = loadAgentPrompt('xdg-agent');
- expect(result.prompt).toBe('xdg prompt');
- });
- test('respects OPENCODE_CONFIG_DIR for prompt location', () => {
- const customDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'omc-prompt-config-'),
- );
- process.env.OPENCODE_CONFIG_DIR = customDir;
- const promptsDir = path.join(customDir, 'oh-my-opencode-slim');
- fs.mkdirSync(promptsDir, { recursive: true });
- fs.writeFileSync(
- path.join(promptsDir, 'oracle.md'),
- 'prompt from OPENCODE_CONFIG_DIR dir',
- );
- const result = loadAgentPrompt('oracle');
- expect(result.prompt).toBe('prompt from OPENCODE_CONFIG_DIR dir');
- fs.rmSync(customDir, { recursive: true, force: true });
- });
- test('falls back to default prompt dir when OPENCODE_CONFIG_DIR has no prompt', () => {
- const customDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'omc-prompt-config-empty-'),
- );
- process.env.OPENCODE_CONFIG_DIR = customDir;
- const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
- fs.mkdirSync(promptsDir, { recursive: true });
- fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'fallback prompt');
- const result = loadAgentPrompt('oracle');
- expect(result.prompt).toBe('fallback prompt');
- fs.rmSync(customDir, { recursive: true, force: true });
- });
- });
- describe('env variable interpolation', () => {
- let tempDir: string;
- let originalEnv: typeof process.env;
- beforeEach(() => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-interp-test-'));
- originalEnv = { ...process.env };
- delete process.env.OPENCODE_CONFIG_DIR;
- process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
- });
- afterEach(() => {
- fs.rmSync(tempDir, { recursive: true, force: true });
- process.env = originalEnv;
- });
- test('basic substitution: {env:FOO} resolves to the value of FOO', () => {
- process.env.FOO = 'foo-model';
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: { oracle: { model: '{env:FOO}' } },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('foo-model');
- });
- test('multiple variables: multiple {env:...} tokens resolve', () => {
- process.env.FOO = 'foo-model';
- process.env.BAR = 'bar-model';
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: {
- oracle: { model: '{env:FOO}' },
- explorer: { model: '{env:BAR}' },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('foo-model');
- expect(config.agents?.explorer?.model).toBe('bar-model');
- });
- test('undefined variable: {env:NONEXISTENT} resolves to empty string', () => {
- delete process.env.NONEXISTENT;
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: { oracle: { model: '{env:NONEXISTENT}' } },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('');
- });
- test('no interpolation needed: config without {env:...} works unchanged', () => {
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: { oracle: { model: 'foo-model' } },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('foo-model');
- });
- test('partial string: "prefix-{env:FOO}-suffix" resolves correctly', () => {
- process.env.FOO = 'foo';
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: { oracle: { model: 'prefix-{env:FOO}-suffix' } },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('prefix-foo-suffix');
- });
- test('works in JSONC files with comments', () => {
- process.env.FOO = 'foo-model';
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
- `{
- // Use env variable for model
- "agents": { "oracle": { "model": "{env:FOO}" } }
- }`,
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('foo-model');
- });
- test('multiple env vars in a single value', () => {
- process.env.FOO = 'foo';
- process.env.BAR = 'bar';
- const projectDir = path.join(tempDir, 'project');
- const projectConfigDir = path.join(projectDir, '.opencode');
- fs.mkdirSync(projectConfigDir, { recursive: true });
- fs.writeFileSync(
- path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
- JSON.stringify({
- agents: {
- oracle: { model: '{env:FOO}/{env:BAR}' },
- },
- }),
- );
- const config = loadPluginConfig(projectDir);
- expect(config.agents?.oracle?.model).toBe('foo/bar');
- });
- });
|