diff --git a/.changeset/yellow-showers-heal.md b/.changeset/yellow-showers-heal.md new file mode 100644 index 00000000..e403b25b --- /dev/null +++ b/.changeset/yellow-showers-heal.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": minor +--- + +Add OpenCode profile with AGENTS.md and MCP config + +- Resolves #965 diff --git a/src/constants/profiles.js b/src/constants/profiles.js index edc59fe1..8521b4d8 100644 --- a/src/constants/profiles.js +++ b/src/constants/profiles.js @@ -1,5 +1,5 @@ /** - * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile + * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'opencode' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile */ /** @@ -16,6 +16,7 @@ * - codex: Codex integration * - cursor: Cursor IDE rules * - gemini: Gemini integration + * - opencode: OpenCode integration * - roo: Roo Code IDE rules * - trae: Trae IDE rules * - vscode: VS Code with GitHub Copilot integration @@ -34,6 +35,7 @@ export const RULE_PROFILES = [ 'codex', 'cursor', 'gemini', + 'opencode', 'roo', 'trae', 'vscode', diff --git a/src/profiles/index.js b/src/profiles/index.js index e353533c..202f2663 100644 --- a/src/profiles/index.js +++ b/src/profiles/index.js @@ -5,6 +5,7 @@ export { clineProfile } from './cline.js'; export { codexProfile } from './codex.js'; export { cursorProfile } from './cursor.js'; export { geminiProfile } from './gemini.js'; +export { opencodeProfile } from './opencode.js'; export { rooProfile } from './roo.js'; export { traeProfile } from './trae.js'; export { vscodeProfile } from './vscode.js'; diff --git a/src/profiles/opencode.js b/src/profiles/opencode.js new file mode 100644 index 00000000..8705abcb --- /dev/null +++ b/src/profiles/opencode.js @@ -0,0 +1,183 @@ +// Opencode profile for rule-transformer +import path from 'path'; +import fs from 'fs'; +import { log } from '../../scripts/modules/utils.js'; +import { createProfile } from './base-profile.js'; + +/** + * Transform standard MCP config format to OpenCode format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed OpenCode configuration object + */ +function transformToOpenCodeFormat(mcpConfig) { + const openCodeConfig = { + $schema: 'https://opencode.ai/config.json' + }; + + // Transform mcpServers to mcp + if (mcpConfig.mcpServers) { + openCodeConfig.mcp = {}; + + for (const [serverName, serverConfig] of Object.entries( + mcpConfig.mcpServers + )) { + // Transform server configuration + const transformedServer = { + type: 'local' + }; + + // Combine command and args into single command array + if (serverConfig.command && serverConfig.args) { + transformedServer.command = [ + serverConfig.command, + ...serverConfig.args + ]; + } else if (serverConfig.command) { + transformedServer.command = [serverConfig.command]; + } + + // Add enabled flag + transformedServer.enabled = true; + + // Transform env to environment + if (serverConfig.env) { + transformedServer.environment = serverConfig.env; + } + + // update with transformed config + openCodeConfig.mcp[serverName] = transformedServer; + } + } + + return openCodeConfig; +} + +/** + * Lifecycle function called after MCP config generation to transform to OpenCode format + * @param {string} targetDir - Target project directory + * @param {string} assetsDir - Assets directory (unused for OpenCode) + */ +function onPostConvertRulesProfile(targetDir, assetsDir) { + const openCodeConfigPath = path.join(targetDir, 'opencode.json'); + + if (!fs.existsSync(openCodeConfigPath)) { + log('debug', '[OpenCode] No opencode.json found to transform'); + return; + } + + try { + // Read the generated standard MCP config + const mcpConfigContent = fs.readFileSync(openCodeConfigPath, 'utf8'); + const mcpConfig = JSON.parse(mcpConfigContent); + + // Check if it's already in OpenCode format (has $schema) + if (mcpConfig.$schema) { + log( + 'info', + '[OpenCode] opencode.json already in OpenCode format, skipping transformation' + ); + return; + } + + // Transform to OpenCode format + const openCodeConfig = transformToOpenCodeFormat(mcpConfig); + + // Write back the transformed config with proper formatting + fs.writeFileSync( + openCodeConfigPath, + JSON.stringify(openCodeConfig, null, 2) + '\n' + ); + + log('info', '[OpenCode] Transformed opencode.json to OpenCode format'); + log( + 'debug', + `[OpenCode] Added schema, renamed mcpServers->mcp, combined command+args, added type/enabled, renamed env->environment` + ); + } catch (error) { + log( + 'error', + `[OpenCode] Failed to transform opencode.json: ${error.message}` + ); + } +} + +/** + * Lifecycle function called when removing OpenCode profile + * @param {string} targetDir - Target project directory + */ +function onRemoveRulesProfile(targetDir) { + const openCodeConfigPath = path.join(targetDir, 'opencode.json'); + + if (!fs.existsSync(openCodeConfigPath)) { + log('debug', '[OpenCode] No opencode.json found to clean up'); + return; + } + + try { + // Read the current config + const configContent = fs.readFileSync(openCodeConfigPath, 'utf8'); + const config = JSON.parse(configContent); + + // Check if it has the mcp section and taskmaster-ai server + if (config.mcp && config.mcp['taskmaster-ai']) { + // Remove taskmaster-ai server + delete config.mcp['taskmaster-ai']; + + // Check if there are other MCP servers + const remainingServers = Object.keys(config.mcp); + + if (remainingServers.length === 0) { + // No other servers, remove entire mcp section + delete config.mcp; + } + + // Check if config is now empty (only has $schema) + const remainingKeys = Object.keys(config).filter( + (key) => key !== '$schema' + ); + + if (remainingKeys.length === 0) { + // Config only has schema left, remove entire file + fs.rmSync(openCodeConfigPath, { force: true }); + log('info', '[OpenCode] Removed empty opencode.json file'); + } else { + // Write back the modified config + fs.writeFileSync( + openCodeConfigPath, + JSON.stringify(config, null, 2) + '\n' + ); + log( + 'info', + '[OpenCode] Removed TaskMaster from opencode.json, preserved other configurations' + ); + } + } else { + log('debug', '[OpenCode] TaskMaster not found in opencode.json'); + } + } catch (error) { + log( + 'error', + `[OpenCode] Failed to clean up opencode.json: ${error.message}` + ); + } +} + +// Create and export opencode profile using the base factory +export const opencodeProfile = createProfile({ + name: 'opencode', + displayName: 'OpenCode', + url: 'opencode.ai', + docsUrl: 'opencode.ai/docs/', + profileDir: '.', // Root directory + rulesDir: '.', // Root directory for AGENTS.md + mcpConfigName: 'opencode.json', // Override default 'mcp.json' + includeDefaultRules: false, + fileMap: { + 'AGENTS.md': 'AGENTS.md' + }, + onPostConvert: onPostConvertRulesProfile, + onRemove: onRemoveRulesProfile +}); + +// Export lifecycle functions separately to avoid naming conflicts +export { onPostConvertRulesProfile, onRemoveRulesProfile }; diff --git a/src/utils/profiles.js b/src/utils/profiles.js index cdf9cbd0..567ee9ec 100644 --- a/src/utils/profiles.js +++ b/src/utils/profiles.js @@ -113,14 +113,12 @@ export async function runInteractiveProfilesSetup() { const hasMcpConfig = profile.mcpConfig === true; if (!profile.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini, zed, amp) - don't include standard coding rules + // Integration guide profiles (claude, codex, gemini, opencode, zed, amp) - don't include standard coding rules if (profileName === 'claude') { description = 'Integration guide with Task Master slash commands'; } else if (profileName === 'codex') { description = 'Comprehensive Task Master integration guide'; - } else if (profileName === 'gemini' || profileName === 'zed') { - description = 'Integration guide and MCP config'; - } else if (profileName === 'amp') { + } else if (hasMcpConfig) { description = 'Integration guide and MCP config'; } else { description = 'Integration guide'; diff --git a/tests/integration/profiles/opencode-init-functionality.test.js b/tests/integration/profiles/opencode-init-functionality.test.js new file mode 100644 index 00000000..5b3c02cc --- /dev/null +++ b/tests/integration/profiles/opencode-init-functionality.test.js @@ -0,0 +1,85 @@ +import fs from 'fs'; +import path from 'path'; +import { opencodeProfile } from '../../../src/profiles/opencode.js'; + +describe('OpenCode Profile Initialization Functionality', () => { + let opencodeProfileContent; + + beforeAll(() => { + const opencodeJsPath = path.join( + process.cwd(), + 'src', + 'profiles', + 'opencode.js' + ); + opencodeProfileContent = fs.readFileSync(opencodeJsPath, 'utf8'); + }); + + test('opencode.js has correct asset-only profile configuration', () => { + // Check for explicit, non-default values in the source file + expect(opencodeProfileContent).toContain("name: 'opencode'"); + expect(opencodeProfileContent).toContain("displayName: 'OpenCode'"); + expect(opencodeProfileContent).toContain("url: 'opencode.ai'"); + expect(opencodeProfileContent).toContain("docsUrl: 'opencode.ai/docs/'"); + expect(opencodeProfileContent).toContain("profileDir: '.'"); // non-default + expect(opencodeProfileContent).toContain("rulesDir: '.'"); // non-default + expect(opencodeProfileContent).toContain("mcpConfigName: 'opencode.json'"); // non-default + expect(opencodeProfileContent).toContain('includeDefaultRules: false'); // non-default + expect(opencodeProfileContent).toContain("'AGENTS.md': 'AGENTS.md'"); + + // Check the final computed properties on the profile object + expect(opencodeProfile.profileName).toBe('opencode'); + expect(opencodeProfile.displayName).toBe('OpenCode'); + expect(opencodeProfile.profileDir).toBe('.'); + expect(opencodeProfile.rulesDir).toBe('.'); + expect(opencodeProfile.mcpConfig).toBe(true); // computed from mcpConfigName + expect(opencodeProfile.mcpConfigName).toBe('opencode.json'); + expect(opencodeProfile.mcpConfigPath).toBe('opencode.json'); // computed + expect(opencodeProfile.includeDefaultRules).toBe(false); + expect(opencodeProfile.fileMap['AGENTS.md']).toBe('AGENTS.md'); + }); + + test('opencode.js has lifecycle functions for MCP config transformation', () => { + expect(opencodeProfileContent).toContain( + 'function onPostConvertRulesProfile' + ); + expect(opencodeProfileContent).toContain('function onRemoveRulesProfile'); + expect(opencodeProfileContent).toContain('transformToOpenCodeFormat'); + }); + + test('opencode.js handles opencode.json transformation in lifecycle functions', () => { + expect(opencodeProfileContent).toContain('opencode.json'); + expect(opencodeProfileContent).toContain('transformToOpenCodeFormat'); + expect(opencodeProfileContent).toContain('$schema'); + expect(opencodeProfileContent).toContain('mcpServers'); + expect(opencodeProfileContent).toContain('mcp'); + }); + + test('opencode.js has proper error handling in lifecycle functions', () => { + expect(opencodeProfileContent).toContain('try {'); + expect(opencodeProfileContent).toContain('} catch (error) {'); + expect(opencodeProfileContent).toContain('log('); + }); + + test('opencode.js uses custom MCP config name', () => { + // OpenCode uses opencode.json instead of mcp.json + expect(opencodeProfileContent).toContain("mcpConfigName: 'opencode.json'"); + // Should not contain mcp.json as a config value (comments are OK) + expect(opencodeProfileContent).not.toMatch( + /mcpConfigName:\s*['"]mcp\.json['"]/ + ); + }); + + test('opencode.js has transformation logic for OpenCode format', () => { + // Check for transformation function + expect(opencodeProfileContent).toContain('transformToOpenCodeFormat'); + + // Check for specific transformation logic + expect(opencodeProfileContent).toContain('mcpServers'); + expect(opencodeProfileContent).toContain('command'); + expect(opencodeProfileContent).toContain('args'); + expect(opencodeProfileContent).toContain('environment'); + expect(opencodeProfileContent).toContain('enabled'); + expect(opencodeProfileContent).toContain('type'); + }); +}); diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index d9cc2554..6e3aff24 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -5,12 +5,30 @@ import path from 'path'; describe('MCP Configuration Validation', () => { describe('Profile MCP Configuration Properties', () => { const expectedMcpConfigurations = { + amp: { + shouldHaveMcp: true, + expectedDir: '.vscode', + expectedConfigName: 'settings.json', + expectedPath: '.vscode/settings.json' + }, + claude: { + shouldHaveMcp: true, + expectedDir: '.', + expectedConfigName: '.mcp.json', + expectedPath: '.mcp.json' + }, cline: { shouldHaveMcp: false, expectedDir: '.clinerules', expectedConfigName: null, expectedPath: null }, + codex: { + shouldHaveMcp: false, + expectedDir: '.', + expectedConfigName: null, + expectedPath: null + }, cursor: { shouldHaveMcp: true, expectedDir: '.cursor', @@ -23,6 +41,12 @@ describe('MCP Configuration Validation', () => { expectedConfigName: 'settings.json', expectedPath: '.gemini/settings.json' }, + opencode: { + shouldHaveMcp: true, + expectedDir: '.', + expectedConfigName: 'opencode.json', + expectedPath: 'opencode.json' + }, roo: { shouldHaveMcp: true, expectedDir: '.roo', @@ -74,10 +98,18 @@ describe('MCP Configuration Validation', () => { RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - const expectedPath = path.join( - profile.profileDir, - profile.mcpConfigName - ); + // For root directory profiles, path.join('.', filename) normalizes to just 'filename' + // except for Claude which uses '.mcp.json' explicitly + let expectedPath; + if (profile.profileDir === '.') { + if (profileName === 'claude') { + expectedPath = '.mcp.json'; // Claude explicitly uses '.mcp.json' + } else { + expectedPath = profile.mcpConfigName; // Other root profiles normalize to just the filename + } + } else { + expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; + } expect(profile.mcpConfigPath).toBe(expectedPath); } }); @@ -95,13 +127,21 @@ describe('MCP Configuration Validation', () => { }); test('should ensure all MCP-enabled profiles use proper directory structure', () => { + const rootProfiles = ['opencode', 'claude', 'codex']; // Profiles that use root directory for config + RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - // Claude profile uses root directory (.), so its path is just '.mcp.json' - if (profileName === 'claude') { - expect(profile.mcpConfigPath).toBe('.mcp.json'); + if (rootProfiles.includes(profileName)) { + // Root profiles have different patterns + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + // Other root profiles normalize to just the filename (no ./ prefix) + expect(profile.mcpConfigPath).toMatch(/^[\w_.]+$/); + } } else { + // Other profiles should have config files in their specific directories expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/); } } @@ -148,7 +188,7 @@ describe('MCP Configuration Validation', () => { test('should ensure each profile has a unique directory', () => { const profileDirs = new Set(); // Profiles that use root directory (can share the same directory) - const rootProfiles = ['claude', 'codex', 'gemini']; + const rootProfiles = ['claude', 'codex', 'gemini', 'opencode']; // Profiles that intentionally share the same directory const sharedDirectoryProfiles = ['amp', 'vscode']; // Both use .vscode @@ -178,7 +218,7 @@ describe('MCP Configuration Validation', () => { test('should ensure profile directories follow expected naming convention', () => { // Profiles that use root directory for rules - const rootRulesProfiles = ['claude', 'codex', 'gemini']; + const rootRulesProfiles = ['claude', 'codex', 'gemini', 'opencode']; RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); @@ -209,12 +249,15 @@ describe('MCP Configuration Validation', () => { }); // Verify expected MCP-enabled profiles + expect(mcpEnabledProfiles).toContain('amp'); expect(mcpEnabledProfiles).toContain('claude'); expect(mcpEnabledProfiles).toContain('cursor'); expect(mcpEnabledProfiles).toContain('gemini'); + expect(mcpEnabledProfiles).toContain('opencode'); expect(mcpEnabledProfiles).toContain('roo'); expect(mcpEnabledProfiles).toContain('vscode'); expect(mcpEnabledProfiles).toContain('windsurf'); + expect(mcpEnabledProfiles).toContain('zed'); expect(mcpEnabledProfiles).not.toContain('cline'); expect(mcpEnabledProfiles).not.toContain('codex'); expect(mcpEnabledProfiles).not.toContain('trae'); @@ -240,19 +283,31 @@ describe('MCP Configuration Validation', () => { // Verify the path is properly formatted for path.join usage expect(profile.mcpConfigPath.startsWith('/')).toBe(false); - // Claude profile uses root directory (.), so its path is just '.mcp.json' - if (profileName === 'claude') { - expect(profile.mcpConfigPath).toBe('.mcp.json'); + // Root directory profiles have different patterns + if (profile.profileDir === '.') { + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + // Other root profiles (opencode) normalize to just the filename + expect(profile.mcpConfigPath).toBe(profile.mcpConfigName); + } } else { + // Non-root profiles should contain a directory separator expect(profile.mcpConfigPath).toContain('/'); } - // Verify it matches the expected pattern: profileDir/configName - const expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; - // For Claude, path.join('.', '.mcp.json') returns '.mcp.json' - const normalizedExpected = - profileName === 'claude' ? '.mcp.json' : expectedPath; - expect(profile.mcpConfigPath).toBe(normalizedExpected); + // Verify it matches the expected pattern based on how path.join works + let expectedPath; + if (profile.profileDir === '.') { + if (profileName === 'claude') { + expectedPath = '.mcp.json'; // Claude explicitly uses '.mcp.json' + } else { + expectedPath = profile.mcpConfigName; // path.join('.', 'filename') normalizes to 'filename' + } + } else { + expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; + } + expect(profile.mcpConfigPath).toBe(expectedPath); } }); }); @@ -266,8 +321,12 @@ describe('MCP Configuration Validation', () => { const fullPath = path.join(testProjectRoot, profile.mcpConfigPath); // Should result in a proper absolute path - expect(fullPath).toBe(`${testProjectRoot}/${profile.mcpConfigPath}`); - expect(fullPath).toContain(profile.profileDir); + // Note: path.join normalizes paths, so './opencode.json' becomes 'opencode.json' + const normalizedExpectedPath = path.join( + testProjectRoot, + profile.mcpConfigPath + ); + expect(fullPath).toBe(normalizedExpectedPath); expect(fullPath).toContain(profile.mcpConfigName); } }); @@ -280,10 +339,16 @@ describe('MCP Configuration Validation', () => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { // Verify the path structure is correct for the new function signature - if (profileName === 'claude') { - // Claude profile uses root directory, so path is just '.mcp.json' - expect(profile.mcpConfigPath).toBe('.mcp.json'); + if (profile.profileDir === '.') { + // Root directory profiles have special handling + if (profileName === 'claude') { + expect(profile.mcpConfigPath).toBe('.mcp.json'); + } else { + // Other root profiles normalize to just the filename + expect(profile.mcpConfigPath).toBe(profile.mcpConfigName); + } } else { + // Non-root profiles should have profileDir/configName structure const parts = profile.mcpConfigPath.split('/'); expect(parts).toHaveLength(2); // Should be profileDir/configName expect(parts[0]).toBe(profile.profileDir); @@ -295,7 +360,17 @@ describe('MCP Configuration Validation', () => { }); describe('MCP configuration validation', () => { - const mcpProfiles = ['cursor', 'gemini', 'roo', 'windsurf', 'vscode']; + const mcpProfiles = [ + 'amp', + 'claude', + 'cursor', + 'gemini', + 'opencode', + 'roo', + 'windsurf', + 'vscode', + 'zed' + ]; const nonMcpProfiles = ['codex', 'cline', 'trae']; const profilesWithLifecycle = ['claude']; const profilesWithoutLifecycle = ['codex']; @@ -322,20 +397,25 @@ describe('MCP Configuration Validation', () => { }); describe('Profile structure validation', () => { - const mcpProfiles = [ + const allProfiles = [ 'amp', + 'claude', + 'cline', + 'codex', 'cursor', 'gemini', + 'opencode', 'roo', - 'windsurf', - 'cline', 'trae', - 'vscode' + 'vscode', + 'windsurf', + 'zed' ]; const profilesWithLifecycle = ['amp', 'claude']; + const profilesWithPostConvertLifecycle = ['opencode']; const profilesWithoutLifecycle = ['codex']; - test.each(mcpProfiles)( + test.each(allProfiles)( 'should have file mappings for %s profile', (profileName) => { const profile = getRulesProfile(profileName); @@ -361,6 +441,21 @@ describe('MCP Configuration Validation', () => { } ); + test.each(profilesWithPostConvertLifecycle)( + 'should have file mappings and post-convert lifecycle functions for %s profile', + (profileName) => { + const profile = getRulesProfile(profileName); + expect(profile).toBeDefined(); + // OpenCode profile has fileMap and post-convert lifecycle functions + expect(profile.fileMap).toBeDefined(); + expect(typeof profile.fileMap).toBe('object'); + expect(Object.keys(profile.fileMap).length).toBeGreaterThan(0); + expect(profile.onAddRulesProfile).toBeUndefined(); // OpenCode doesn't have onAdd + expect(typeof profile.onRemoveRulesProfile).toBe('function'); + expect(typeof profile.onPostConvertRulesProfile).toBe('function'); + } + ); + test.each(profilesWithoutLifecycle)( 'should have file mappings without lifecycle functions for %s profile', (profileName) => { diff --git a/tests/unit/profiles/opencode-integration.test.js b/tests/unit/profiles/opencode-integration.test.js new file mode 100644 index 00000000..a3daf21c --- /dev/null +++ b/tests/unit/profiles/opencode-integration.test.js @@ -0,0 +1,123 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +describe('OpenCode Profile Integration', () => { + let tempDir; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'task-master-test-')); + + // Spy on fs methods + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((filePath) => { + if (filePath.toString().includes('AGENTS.md')) { + return 'Sample AGENTS.md content for OpenCode integration'; + } + if (filePath.toString().includes('opencode.json')) { + return JSON.stringify({ mcpServers: {} }, null, 2); + } + return '{}'; + }); + jest.spyOn(fs, 'existsSync').mockImplementation(() => false); + jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); + }); + + afterEach(() => { + // Clean up the temporary directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (err) { + console.error(`Error cleaning up: ${err.message}`); + } + }); + + // Test function that simulates the OpenCode profile file copying behavior + function mockCreateOpenCodeStructure() { + // OpenCode profile copies AGENTS.md to AGENTS.md in project root (same name) + const sourceContent = 'Sample AGENTS.md content for OpenCode integration'; + fs.writeFileSync(path.join(tempDir, 'AGENTS.md'), sourceContent); + + // OpenCode profile creates opencode.json config file + const configContent = JSON.stringify({ mcpServers: {} }, null, 2); + fs.writeFileSync(path.join(tempDir, 'opencode.json'), configContent); + } + + test('creates AGENTS.md file in project root', () => { + // Act + mockCreateOpenCodeStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, 'AGENTS.md'), + 'Sample AGENTS.md content for OpenCode integration' + ); + }); + + test('creates opencode.json config file in project root', () => { + // Act + mockCreateOpenCodeStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, 'opencode.json'), + JSON.stringify({ mcpServers: {} }, null, 2) + ); + }); + + test('does not create any profile directories', () => { + // Act + mockCreateOpenCodeStructure(); + + // Assert - OpenCode profile should not create any directories + // Only the temp directory creation calls should exist + const mkdirCalls = fs.mkdirSync.mock.calls.filter( + (call) => !call[0].includes('task-master-test-') + ); + expect(mkdirCalls).toHaveLength(0); + }); + + test('handles transformation of MCP config format', () => { + // This test simulates the transformation behavior that would happen in onPostConvert + const standardMcpConfig = { + mcpServers: { + 'taskmaster-ai': { + command: 'node', + args: ['path/to/server.js'], + env: { + API_KEY: 'test-key' + } + } + } + }; + + const expectedOpenCodeConfig = { + $schema: 'https://opencode.ai/config.json', + mcp: { + 'taskmaster-ai': { + type: 'local', + command: ['node', 'path/to/server.js'], + enabled: true, + environment: { + API_KEY: 'test-key' + } + } + } + }; + + // Mock the transformation behavior + fs.writeFileSync( + path.join(tempDir, 'opencode.json'), + JSON.stringify(expectedOpenCodeConfig, null, 2) + ); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, 'opencode.json'), + JSON.stringify(expectedOpenCodeConfig, null, 2) + ); + }); +}); diff --git a/tests/unit/profiles/rule-transformer-opencode.test.js b/tests/unit/profiles/rule-transformer-opencode.test.js new file mode 100644 index 00000000..74b8dd42 --- /dev/null +++ b/tests/unit/profiles/rule-transformer-opencode.test.js @@ -0,0 +1,59 @@ +import { jest } from '@jest/globals'; +import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; +import { opencodeProfile } from '../../../src/profiles/opencode.js'; + +describe('Rule Transformer - OpenCode Profile', () => { + test('should have correct profile configuration', () => { + const opencodeProfile = getRulesProfile('opencode'); + + expect(opencodeProfile).toBeDefined(); + expect(opencodeProfile.profileName).toBe('opencode'); + expect(opencodeProfile.displayName).toBe('OpenCode'); + expect(opencodeProfile.profileDir).toBe('.'); + expect(opencodeProfile.rulesDir).toBe('.'); + expect(opencodeProfile.mcpConfig).toBe(true); + expect(opencodeProfile.mcpConfigName).toBe('opencode.json'); + expect(opencodeProfile.mcpConfigPath).toBe('opencode.json'); + expect(opencodeProfile.includeDefaultRules).toBe(false); + expect(opencodeProfile.fileMap).toEqual({ + 'AGENTS.md': 'AGENTS.md' + }); + }); + + test('should have lifecycle functions for MCP config transformation', () => { + // Verify that opencode.js has lifecycle functions + expect(opencodeProfile.onPostConvertRulesProfile).toBeDefined(); + expect(typeof opencodeProfile.onPostConvertRulesProfile).toBe('function'); + expect(opencodeProfile.onRemoveRulesProfile).toBeDefined(); + expect(typeof opencodeProfile.onRemoveRulesProfile).toBe('function'); + }); + + test('should use opencode.json instead of mcp.json', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.mcpConfigName).toBe('opencode.json'); + expect(opencodeProfile.mcpConfigPath).toBe('opencode.json'); + }); + + test('should not include default rules', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.includeDefaultRules).toBe(false); + }); + + test('should have correct file mapping', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.fileMap).toEqual({ + 'AGENTS.md': 'AGENTS.md' + }); + }); + + test('should use root directory for both profile and rules', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.profileDir).toBe('.'); + expect(opencodeProfile.rulesDir).toBe('.'); + }); + + test('should have MCP configuration enabled', () => { + const opencodeProfile = getRulesProfile('opencode'); + expect(opencodeProfile.mcpConfig).toBe(true); + }); +}); diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index 33b417c2..c93f957c 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -19,6 +19,7 @@ describe('Rule Transformer - General', () => { 'codex', 'cursor', 'gemini', + 'opencode', 'roo', 'trae', 'vscode', @@ -211,6 +212,11 @@ describe('Rule Transformer - General', () => { mcpConfigName: 'settings.json', expectedPath: '.gemini/settings.json' }, + opencode: { + mcpConfig: true, + mcpConfigName: 'opencode.json', + expectedPath: 'opencode.json' + }, roo: { mcpConfig: true, mcpConfigName: 'mcp.json', @@ -253,11 +259,19 @@ describe('Rule Transformer - General', () => { const profileConfig = getRulesProfile(profile); if (profileConfig.mcpConfig !== false) { // Profiles with MCP configuration should have valid paths - // The mcpConfigPath should start with the profileDir - if (profile === 'claude') { - // Claude uses root directory (.), so path.join('.', '.mcp.json') = '.mcp.json' - expect(profileConfig.mcpConfigPath).toBe('.mcp.json'); + // Handle root directory profiles differently + if (profileConfig.profileDir === '.') { + if (profile === 'claude') { + // Claude explicitly uses '.mcp.json' + expect(profileConfig.mcpConfigPath).toBe('.mcp.json'); + } else { + // Other root profiles normalize to just the filename + expect(profileConfig.mcpConfigPath).toBe( + profileConfig.mcpConfigName + ); + } } else { + // Non-root profiles should have profileDir/configName pattern expect(profileConfig.mcpConfigPath).toMatch( new RegExp( `^${profileConfig.profileDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`