diff --git a/src/config/initializer.test.ts b/src/config/initializer.test.ts index e1184f2..0118d3d 100644 --- a/src/config/initializer.test.ts +++ b/src/config/initializer.test.ts @@ -236,4 +236,58 @@ describe('initializer', () => { expect(validateConfig(loadedConfig)).toBe(true) }) }) + + describe('oh-my-opencode optional preset', () => { + test('validateConfig returns true for preset without oh-my-opencode key', () => { + const config: ModeSwitcherConfig = { + currentMode: 'performance', + showToastOnStartup: true, + presets: { + performance: { + description: 'High-performance models for complex tasks', + model: 'anthropic/claude-sonnet-4', + opencode: { + build: { model: 'anthropic/claude-sonnet-4' }, + }, + // Note: no 'oh-my-opencode' key + }, + }, + } + expect(validateConfig(config)).toBe(true) + }) + + test('validateConfig returns true for config where some presets have oh-my-opencode and some do not', () => { + const config: ModeSwitcherConfig = { + currentMode: 'performance', + showToastOnStartup: true, + presets: { + performance: { + description: 'With oh-my-opencode', + opencode: {}, + 'oh-my-opencode': { agents: {} }, + }, + economy: { + description: 'Without oh-my-opencode', + opencode: {}, + // Note: no 'oh-my-opencode' key + }, + }, + } + expect(validateConfig(config)).toBe(true) + }) + + test('preset without oh-my-opencode key has undefined value for that field', () => { + const preset: ModePreset = { + description: 'No oh-my-opencode', + model: 'anthropic/claude-sonnet-4', + opencode: { + build: { model: 'anthropic/claude-sonnet-4' }, + }, + // Note: no 'oh-my-opencode' key + } + + // Accessing undefined key should return undefined (not throw) + expect(preset['oh-my-opencode']).toBeUndefined() + }) + }) }) diff --git a/src/config/initializer.ts b/src/config/initializer.ts index 441b8fd..b7c9a54 100644 --- a/src/config/initializer.ts +++ b/src/config/initializer.ts @@ -60,6 +60,10 @@ function applyEconomyModel( * files and preserves their entire structure as-is. The hierarchical * structure (agent, agents, categories, etc.) is maintained exactly. * + * If `oh-my-opencode.json` does not exist (i.e., the user does not use the + * oh-my-opencode plugin), the `'oh-my-opencode'` key is omitted from the + * returned preset entirely. + * * @returns Promise resolving to a ModePreset with performance-oriented models * @example * ```typescript @@ -86,7 +90,7 @@ async function buildPerformancePreset(): Promise { description: 'High-performance models for complex tasks', ...(globalModel && { model: globalModel }), opencode: opencodePreset, - 'oh-my-opencode': ohMyOpencodePreset, + ...(ohMyOpencodeConfig && { 'oh-my-opencode': ohMyOpencodePreset }), } } @@ -98,6 +102,10 @@ async function buildPerformancePreset(): Promise { * `opencode/glm-4.7-free` model. The hierarchical structure is * preserved while model values are updated recursively. * + * If `oh-my-opencode.json` does not exist (i.e., the user does not use the + * oh-my-opencode plugin), the `'oh-my-opencode'` key is omitted from the + * returned preset entirely. + * * @returns Promise resolving to a ModePreset with economy-oriented models * @example * ```typescript @@ -125,7 +133,7 @@ async function buildEconomyPreset(): Promise { description: 'Cost-efficient free model for routine tasks', model: DEFAULT_ECONOMY_MODEL, opencode: opencodePreset, - 'oh-my-opencode': ohMyOpencodePreset, + ...(ohMyOpencodeConfig && { 'oh-my-opencode': ohMyOpencodePreset }), } } diff --git a/src/config/types.ts b/src/config/types.ts index 7373fad..60d729f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -33,12 +33,15 @@ export type HierarchicalPreset = Record< * Both opencode and oh-my-opencode use the same HierarchicalPreset type, * allowing them to have arbitrary nested structures that are handled * uniformly by recursive merge functions. + * + * The `oh-my-opencode` field is optional to support users who do not use the + * oh-my-opencode plugin. When absent, all oh-my-opencode related processing is skipped. */ export interface ModePreset { description: string model?: string opencode: HierarchicalPreset - 'oh-my-opencode': HierarchicalPreset + 'oh-my-opencode'?: HierarchicalPreset } /** diff --git a/src/modes/manager.test.ts b/src/modes/manager.test.ts index f7d6c64..338168d 100644 --- a/src/modes/manager.test.ts +++ b/src/modes/manager.test.ts @@ -95,7 +95,7 @@ class MockModeManager { ) } - if (this.ohMyConfig) { + if (this.ohMyConfig && preset['oh-my-opencode']) { deepMergeModel( this.ohMyConfig as Record, preset['oh-my-opencode'] @@ -130,6 +130,7 @@ class MockModeManager { // Check oh-my-opencode: recursively if ( this.ohMyConfig && + preset['oh-my-opencode'] && hasDriftRecursive( this.ohMyConfig as Record, preset['oh-my-opencode'] @@ -188,7 +189,9 @@ class MockModeManager { : 'Global model: (not set)' const opencodeTree = formatHierarchicalTree(preset.opencode) - const ohMyOpencodeTree = formatHierarchicalTree(preset['oh-my-opencode']) + const ohMyOpencodeTree = preset['oh-my-opencode'] + ? formatHierarchicalTree(preset['oh-my-opencode']) + : '' return [ `Current mode: ${currentMode}`, @@ -222,7 +225,9 @@ class MockModeManager { } // Simulate updating oh-my-opencode.json - if (this.ohMyConfig) { + if (!preset['oh-my-opencode']) { + results.push('oh-my-opencode.json: skipped (not configured)') + } else if (this.ohMyConfig) { results.push('oh-my-opencode.json: updated') } else { results.push('oh-my-opencode.json: skipped (not found)') @@ -657,4 +662,103 @@ describe('ModeManager', () => { expect(manager.lastDriftToast).not.toBeNull() }) }) + + describe('oh-my-opencode optional (no oh-my-opencode key in preset)', () => { + /** + * A config without 'oh-my-opencode' key in the preset, simulating + * a user who does not use oh-my-opencode. + */ + function cloneConfigWithoutOhMy(): ModeSwitcherConfig { + return { + currentMode: 'performance', + showToastOnStartup: true, + presets: { + performance: { + description: 'High-performance models for complex tasks', + model: 'anthropic/claude-sonnet-4', + opencode: { + build: { model: 'anthropic/claude-sonnet-4' }, + plan: { model: 'anthropic/claude-sonnet-4' }, + }, + // Note: no 'oh-my-opencode' key + }, + economy: { + description: 'Cost-efficient free model for routine tasks', + model: 'opencode/glm-4.7-free', + opencode: { + build: { model: 'opencode/glm-4.7-free' }, + plan: { model: 'opencode/glm-4.7-free' }, + }, + // Note: no 'oh-my-opencode' key + }, + }, + } + } + + test('switchMode returns "skipped (not configured)" for oh-my-opencode when preset has no oh-my-opencode key', async () => { + manager.setConfig(cloneConfigWithoutOhMy()) + manager.setOpencodeConfig(sampleConfigs.opencodeConfig) + manager.setOhMyConfig(sampleConfigs.ohMyOpencodeConfig) + await manager.initialize() + + const result = await manager.switchMode('economy') + expect(result).toContain('oh-my-opencode.json: skipped (not configured)') + }) + + test('getStatus does not crash when preset has no oh-my-opencode key', async () => { + manager.setConfig(cloneConfigWithoutOhMy()) + await manager.initialize() + + const status = await manager.getStatus() + expect(status).toContain('Current mode: performance') + expect(status).toContain('Oh-my-opencode config:') + expect(status).toContain('(none configured)') + }) + + test('initialize does not crash on drift check when preset has no oh-my-opencode key', async () => { + const config = cloneConfigWithoutOhMy() + config.currentMode = 'economy' + manager.setConfig(config) + // opencode drifted, oh-my-opencode present in FS but preset has no oh-my-opencode key + manager.setOpencodeConfig({ + model: 'anthropic/claude-sonnet-4', // mismatch with economy + agent: { + build: { model: 'anthropic/claude-sonnet-4' }, + plan: { model: 'anthropic/claude-sonnet-4' }, + }, + }) + manager.setOhMyConfig(sampleConfigs.ohMyOpencodeConfig) + + // Should not throw + await manager.initialize() + + // Drift detected via opencode config, so toast should be set + expect(manager.lastDriftToast).not.toBeNull() + expect(manager.lastDriftToast).toContain('economy') + }) + + test('initialize does not apply oh-my-opencode changes when preset has no oh-my-opencode key', async () => { + const config = cloneConfigWithoutOhMy() + config.currentMode = 'economy' + manager.setConfig(config) + manager.setOpencodeConfig({ + model: 'opencode/glm-4.7-free', // already matching + agent: { + build: { model: 'opencode/glm-4.7-free' }, + plan: { model: 'opencode/glm-4.7-free' }, + }, + }) + manager.setOhMyConfig({ + agents: { + sisyphus: { model: 'anthropic/claude-sonnet-4' }, // different model + }, + }) + + await manager.initialize() + + // No drift (opencode matches), so toast should not be set + // and oh-my-opencode should NOT be modified (preset has no oh-my-opencode) + expect(manager.lastDriftToast).toBeNull() + }) + }) }) diff --git a/src/modes/manager.ts b/src/modes/manager.ts index dbba430..bfe1490 100644 --- a/src/modes/manager.ts +++ b/src/modes/manager.ts @@ -259,7 +259,9 @@ export class ModeManager { // Apply the preset to actual config files await this.updateOpencodeConfig(preset.model, preset.opencode) - await this.updateOhMyOpencodeConfig(preset['oh-my-opencode']) + if (preset['oh-my-opencode']) { + await this.updateOhMyOpencodeConfig(preset['oh-my-opencode']) + } // Notify user to restart (fire-and-forget to avoid blocking // plugin initialization when UI is not yet ready). @@ -288,6 +290,10 @@ export class ModeManager { * Checks global model and per-agent model values recursively. Returns true * if any expected value differs from the actual file content. * + * If the preset does not include an `'oh-my-opencode'` key (i.e., the user + * does not use the oh-my-opencode plugin), drift checking for that config + * file is skipped entirely. + * * @param preset - The mode preset to compare against * @returns True if actual configs differ from the preset * @private @@ -317,9 +323,10 @@ export class ModeManager { return true } - // Check oh-my-opencode: recursively check + // Check oh-my-opencode: recursively check (skip if preset has no oh-my-opencode config) if ( ohMyConfig && + preset['oh-my-opencode'] && hasDriftRecursive( ohMyConfig as Record, preset['oh-my-opencode'] @@ -437,8 +444,10 @@ export class ModeManager { // opencode: recursively format tree const opencodeTree = formatHierarchicalTree(preset.opencode) - // oh-my-opencode: recursively format tree - const ohMyOpencodeTree = formatHierarchicalTree(preset['oh-my-opencode']) + // oh-my-opencode: recursively format tree (skip if not configured) + const ohMyOpencodeTree = preset['oh-my-opencode'] + ? formatHierarchicalTree(preset['oh-my-opencode']) + : '' return [ `Current mode: ${currentMode}`, @@ -459,7 +468,7 @@ export class ModeManager { * This method performs the following operations: * 1. Validates that the requested mode exists * 2. Updates `opencode.json` with new global model and agent settings - * 3. Updates `oh-my-opencode.json` with new agent settings + * 3. Updates `oh-my-opencode.json` with new agent settings (skipped if preset has no `'oh-my-opencode'` key) * 4. Updates `agent-mode-switcher.json` with the new current mode * 5. Shows a toast notification (if available) * @@ -470,6 +479,7 @@ export class ModeManager { * @returns Promise resolving to a formatted result message with status of each config update * @example * ```typescript + * // With oh-my-opencode configured: * const result = await manager.switchMode('economy'); * console.log(result); * // Output: @@ -482,6 +492,18 @@ export class ModeManager { * // - agent-mode-switcher.json: updated * // * // Note: Restart opencode to apply changes. + * + * // Without oh-my-opencode configured: + * // Output: + * // Switched to economy mode + * // Cost-efficient free model for routine tasks + * // + * // Results: + * // - opencode.json: updated + * // - oh-my-opencode.json: skipped (not configured) + * // - agent-mode-switcher.json: updated + * // + * // Note: Restart opencode to apply changes. * ``` */ async switchMode(modeName: string): Promise { @@ -502,10 +524,10 @@ export class ModeManager { ) results.push(`opencode.json: ${opencodeResult}`) - // 2. Update oh-my-opencode.json directly (agents section only) - const ohMyResult = await this.updateOhMyOpencodeConfig( - preset['oh-my-opencode'] - ) + // 2. Update oh-my-opencode.json directly (agents section only, skip if not configured) + const ohMyResult = preset['oh-my-opencode'] + ? await this.updateOhMyOpencodeConfig(preset['oh-my-opencode']) + : 'skipped (not configured)' results.push(`oh-my-opencode.json: ${ohMyResult}`) // 3. Update plugin configuration