Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/yellow-showers-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"task-master-ai": minor
---

Add OpenCode profile with AGENTS.md and MCP config

- Resolves #965
4 changes: 3 additions & 1 deletion src/constants/profiles.js
Original file line number Diff line number Diff line change
@@ -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
*/

/**
Expand All @@ -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
Expand All @@ -34,6 +35,7 @@ export const RULE_PROFILES = [
'codex',
'cursor',
'gemini',
'opencode',
'roo',
'trae',
'vscode',
Expand Down
1 change: 1 addition & 0 deletions src/profiles/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
183 changes: 183 additions & 0 deletions src/profiles/opencode.js
Original file line number Diff line number Diff line change
@@ -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 };
6 changes: 2 additions & 4 deletions src/utils/profiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
85 changes: 85 additions & 0 deletions tests/integration/profiles/opencode-init-functionality.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading