diff --git a/.changeset/fix-show-command-complexity.md b/.changeset/fix-show-command-complexity.md new file mode 100644 index 00000000..c73e131d --- /dev/null +++ b/.changeset/fix-show-command-complexity.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": patch +--- + +Fix: show command no longer requires complexity report file to exist + +The `tm show` command was incorrectly requiring the complexity report file to exist even when not needed. Now it only validates the complexity report path when a custom report file is explicitly provided via the -r/--report option. \ No newline at end of file diff --git a/.changeset/groq-kimi-k2-support.md b/.changeset/groq-kimi-k2-support.md new file mode 100644 index 00000000..663a0f5f --- /dev/null +++ b/.changeset/groq-kimi-k2-support.md @@ -0,0 +1,10 @@ +--- +"task-master-ai": minor +--- + +Complete Groq provider integration and add MoonshotAI Kimi K2 model support + +- Fixed Groq provider registration +- Added Groq API key validation +- Added GROQ_API_KEY to .env.example +- Added moonshotai/kimi-k2-instruct model with $1/$3 per 1M token pricing and 16k max output \ No newline at end of file diff --git a/.changeset/metal-papers-stay.md b/.changeset/metal-papers-stay.md new file mode 100644 index 00000000..6b957f81 --- /dev/null +++ b/.changeset/metal-papers-stay.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": minor +--- + +feat: Add Zed editor rule profile with agent rules and MCP config + +- Resolves #637 \ No newline at end of file diff --git a/.changeset/public-crabs-ask.md b/.changeset/public-crabs-ask.md new file mode 100644 index 00000000..f122c1e8 --- /dev/null +++ b/.changeset/public-crabs-ask.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": minor +--- + +Add Amp rule profile with AGENT.md and MCP config diff --git a/.changeset/swift-turtles-sit.md b/.changeset/swift-turtles-sit.md new file mode 100644 index 00000000..b5f57475 --- /dev/null +++ b/.changeset/swift-turtles-sit.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Add MCP configuration support to Claude Code rules 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/.changeset/yummy-walls-eat.md b/.changeset/yummy-walls-eat.md new file mode 100644 index 00000000..64df1d3c --- /dev/null +++ b/.changeset/yummy-walls-eat.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Add missing API keys to .env.example and README.md diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..0f96eb68 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,10 @@ +reviews: + profile: assertive + poem: false + auto_review: + base_branches: + - rc + - beta + - alpha + - production + - next \ No newline at end of file diff --git a/.env.example b/.env.example index 54429bf5..b97c1efd 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ GROQ_API_KEY=YOUR_GROQ_KEY_HERE OPENROUTER_API_KEY=YOUR_OPENROUTER_KEY_HERE XAI_API_KEY=YOUR_XAI_KEY_HERE AZURE_OPENAI_API_KEY=YOUR_AZURE_KEY_HERE +OLLAMA_API_KEY=YOUR_OLLAMA_API_KEY_HERE # Google Vertex AI Configuration VERTEX_PROJECT_ID=your-gcp-project-id diff --git a/README.md b/README.md index 617688f9..075922d7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ MCP (Model Control Protocol) lets you run Task Master directly from your editor. "OPENAI_API_KEY": "YOUR_OPENAI_KEY_HERE", "GOOGLE_API_KEY": "YOUR_GOOGLE_KEY_HERE", "MISTRAL_API_KEY": "YOUR_MISTRAL_KEY_HERE", + "GROQ_API_KEY": "YOUR_GROQ_KEY_HERE", "OPENROUTER_API_KEY": "YOUR_OPENROUTER_KEY_HERE", "XAI_API_KEY": "YOUR_XAI_KEY_HERE", "AZURE_OPENAI_API_KEY": "YOUR_AZURE_KEY_HERE", @@ -110,9 +111,11 @@ MCP (Model Control Protocol) lets you run Task Master directly from your editor. "OPENAI_API_KEY": "YOUR_OPENAI_KEY_HERE", "GOOGLE_API_KEY": "YOUR_GOOGLE_KEY_HERE", "MISTRAL_API_KEY": "YOUR_MISTRAL_KEY_HERE", + "GROQ_API_KEY": "YOUR_GROQ_KEY_HERE", "OPENROUTER_API_KEY": "YOUR_OPENROUTER_KEY_HERE", "XAI_API_KEY": "YOUR_XAI_KEY_HERE", - "AZURE_OPENAI_API_KEY": "YOUR_AZURE_KEY_HERE" + "AZURE_OPENAI_API_KEY": "YOUR_AZURE_KEY_HERE", + "OLLAMA_API_KEY": "YOUR_OLLAMA_API_KEY_HERE" }, "type": "stdio" } diff --git a/apps/extension/src/index.ts b/apps/extension/src/index.ts new file mode 100644 index 00000000..6be02374 --- /dev/null +++ b/apps/extension/src/index.ts @@ -0,0 +1 @@ +console.log('hello world'); diff --git a/assets/AGENTS.md b/assets/AGENTS.md index 83f3f786..6f664815 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -1,4 +1,4 @@ -# Task Master AI - Claude Code Integration Guide +# Task Master AI - Agent Integration Guide ## Essential Commands diff --git a/assets/env.example b/assets/env.example index 2c5babf0..60bd23e8 100644 --- a/assets/env.example +++ b/assets/env.example @@ -1,10 +1,12 @@ # API Keys (Required to enable respective provider) ANTHROPIC_API_KEY="your_anthropic_api_key_here" # Required: Format: sk-ant-api03-... PERPLEXITY_API_KEY="your_perplexity_api_key_here" # Optional: Format: pplx-... -OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI/OpenRouter models. Format: sk-proj-... +OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI models. Format: sk-proj-... GOOGLE_API_KEY="your_google_api_key_here" # Optional, for Google Gemini models. MISTRAL_API_KEY="your_mistral_key_here" # Optional, for Mistral AI models. XAI_API_KEY="YOUR_XAI_KEY_HERE" # Optional, for xAI AI models. +GROQ_API_KEY="YOUR_GROQ_KEY_HERE" # Optional, for Groq models. +OPENROUTER_API_KEY="YOUR_OPENROUTER_KEY_HERE" # Optional, for OpenRouter models. AZURE_OPENAI_API_KEY="your_azure_key_here" # Optional, for Azure OpenAI models (requires endpoint in .taskmaster/config.json). OLLAMA_API_KEY="your_ollama_api_key_here" # Optional: For remote Ollama servers that require authentication. GITHUB_API_KEY="your_github_api_key_here" # Optional: For GitHub import/export features. Format: ghp_... or github_pat_... \ No newline at end of file diff --git a/docs/models.md b/docs/models.md index a5d12ef0..733a9ca3 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,4 +1,4 @@ -# Available Models as of July 10, 2025 +# Available Models as of July 16, 2025 ## Main Models @@ -32,6 +32,7 @@ | xai | grok-3 | — | 3 | 15 | | xai | grok-3-fast | — | 5 | 25 | | xai | grok-4 | — | 3 | 15 | +| groq | moonshotai/kimi-k2-instruct | 0.66 | 1 | 3 | | groq | llama-3.3-70b-versatile | 0.55 | 0.59 | 0.79 | | groq | llama-3.1-8b-instant | 0.32 | 0.05 | 0.08 | | groq | llama-4-scout | 0.45 | 0.11 | 0.34 | @@ -144,6 +145,7 @@ | xai | grok-3 | — | 3 | 15 | | xai | grok-3-fast | — | 5 | 25 | | xai | grok-4 | — | 3 | 15 | +| groq | moonshotai/kimi-k2-instruct | 0.66 | 1 | 3 | | groq | llama-3.3-70b-versatile | 0.55 | 0.59 | 0.79 | | groq | llama-3.1-8b-instant | 0.32 | 0.05 | 0.08 | | groq | llama-4-scout | 0.45 | 0.11 | 0.34 | diff --git a/package-lock.json b/package-lock.json index 0b4a73aa..32e89af1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13304,6 +13304,16 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "dev": true, + "license": "MIT" + }, + "node_modules/extension": { + "resolved": "apps/extension", + "link": true + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", diff --git a/scripts/modules/ai-services-unified.js b/scripts/modules/ai-services-unified.js index aefae8dc..0df4bd1a 100644 --- a/scripts/modules/ai-services-unified.js +++ b/scripts/modules/ai-services-unified.js @@ -8,47 +8,48 @@ // --- Core Dependencies --- import { - getMainProvider, - getMainModelId, - getResearchProvider, - getResearchModelId, - getFallbackProvider, + MODEL_MAP, + getAzureBaseURL, + getBaseUrlForRole, + getBedrockBaseURL, + getDebugFlag, getFallbackModelId, + getFallbackProvider, + getMainModelId, + getMainProvider, + getOllamaBaseURL, getParametersForRole, + getResearchModelId, + getResearchProvider, getResponseLanguage, getUserId, - MODEL_MAP, - getDebugFlag, - getBaseUrlForRole, - isApiKeySet, - getOllamaBaseURL, - getAzureBaseURL, - getBedrockBaseURL, - getVertexProjectId, getVertexLocation, + getVertexProjectId, + isApiKeySet, providersWithoutApiKeys } from './config-manager.js'; import { - log, findProjectRoot, - resolveEnvVariable, - getCurrentTag + getCurrentTag, + log, + resolveEnvVariable } from './utils.js'; // Import provider classes import { AnthropicAIProvider, - PerplexityAIProvider, - GoogleAIProvider, - OpenAIProvider, - XAIProvider, - OpenRouterAIProvider, - OllamaAIProvider, - BedrockAIProvider, AzureProvider, - VertexAIProvider, + BedrockAIProvider, ClaudeCodeProvider, - GeminiCliProvider + GeminiCliProvider, + GoogleAIProvider, + GroqProvider, + OllamaAIProvider, + OpenAIProvider, + OpenRouterAIProvider, + PerplexityAIProvider, + VertexAIProvider, + XAIProvider } from '../../src/ai-providers/index.js'; // Import the provider registry @@ -61,6 +62,7 @@ const PROVIDERS = { google: new GoogleAIProvider(), openai: new OpenAIProvider(), xai: new XAIProvider(), + groq: new GroqProvider(), openrouter: new OpenRouterAIProvider(), ollama: new OllamaAIProvider(), bedrock: new BedrockAIProvider(), diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 4dabf8a7..f68d4706 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -2353,10 +2353,14 @@ ${result.result} .option('--tag ', 'Specify tag context for task operations') .action(async (taskId, options) => { // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true, - complexityReportPath: options.report || false - }); + const initOptions = { + tasksPath: options.file || true + }; + // Only pass complexityReportPath if user provided a custom path + if (options.report && options.report !== COMPLEXITY_REPORT_FILE) { + initOptions.complexityReportPath = options.report; + } + const taskMaster = initTaskMaster(initOptions); const idArg = taskId || options.id; const statusFilter = options.status; diff --git a/scripts/modules/config-manager.js b/scripts/modules/config-manager.js index e688897b..ed9a3ebc 100644 --- a/scripts/modules/config-manager.js +++ b/scripts/modules/config-manager.js @@ -1,21 +1,21 @@ import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import chalk from 'chalk'; import { z } from 'zod'; -import { fileURLToPath } from 'url'; -import { log, findProjectRoot, resolveEnvVariable, isEmpty } from './utils.js'; +import { AI_COMMAND_NAMES } from '../../src/constants/commands.js'; import { LEGACY_CONFIG_FILE, TASKMASTER_DIR } from '../../src/constants/paths.js'; -import { findConfigPath } from '../../src/utils/path-utils.js'; import { - VALIDATED_PROVIDERS, + ALL_PROVIDERS, CUSTOM_PROVIDERS, CUSTOM_PROVIDERS_ARRAY, - ALL_PROVIDERS + VALIDATED_PROVIDERS } from '../../src/constants/providers.js'; -import { AI_COMMAND_NAMES } from '../../src/constants/commands.js'; +import { findConfigPath } from '../../src/utils/path-utils.js'; +import { findProjectRoot, isEmpty, log, resolveEnvVariable } from './utils.js'; // Calculate __dirname in ESM const __filename = fileURLToPath(import.meta.url); @@ -641,6 +641,7 @@ function isApiKeySet(providerName, session = null, projectRoot = null) { azure: 'AZURE_OPENAI_API_KEY', openrouter: 'OPENROUTER_API_KEY', xai: 'XAI_API_KEY', + groq: 'GROQ_API_KEY', vertex: 'GOOGLE_API_KEY', // Vertex uses the same key as Google 'claude-code': 'CLAUDE_CODE_API_KEY', // Not actually used, but included for consistency bedrock: 'AWS_ACCESS_KEY_ID' // Bedrock uses AWS credentials @@ -726,6 +727,10 @@ function getMcpApiKeyStatus(providerName, projectRoot = null) { apiKeyToCheck = mcpEnv.XAI_API_KEY; placeholderValue = 'YOUR_XAI_API_KEY_HERE'; break; + case 'groq': + apiKeyToCheck = mcpEnv.GROQ_API_KEY; + placeholderValue = 'YOUR_GROQ_API_KEY_HERE'; + break; case 'ollama': return true; // No key needed case 'claude-code': diff --git a/scripts/modules/supported-models.json b/scripts/modules/supported-models.json index 0960b2b9..a321e6ac 100644 --- a/scripts/modules/supported-models.json +++ b/scripts/modules/supported-models.json @@ -295,6 +295,16 @@ } ], "groq": [ + { + "id": "moonshotai/kimi-k2-instruct", + "swe_score": 0.66, + "cost_per_1m_tokens": { + "input": 1.0, + "output": 3.0 + }, + "allowed_roles": ["main", "fallback"], + "max_tokens": 16384 + }, { "id": "llama-3.3-70b-versatile", "swe_score": 0.55, diff --git a/src/ai-providers/groq.js b/src/ai-providers/groq.js index f8eda87d..8acbd6df 100644 --- a/src/ai-providers/groq.js +++ b/src/ai-providers/groq.js @@ -14,6 +14,14 @@ export class GroqProvider extends BaseAIProvider { this.name = 'Groq'; } + /** + * Returns the environment variable name required for this provider's API key. + * @returns {string} The environment variable name for the Groq API key + */ + getRequiredApiKeyName() { + return 'GROQ_API_KEY'; + } + /** * Creates and returns a Groq client instance. * @param {object} params - Parameters for client initialization diff --git a/src/constants/profiles.js b/src/constants/profiles.js index 861ed406..8521b4d8 100644 --- a/src/constants/profiles.js +++ b/src/constants/profiles.js @@ -1,5 +1,5 @@ /** - * @typedef {'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode'} RulesProfile + * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'opencode' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile */ /** @@ -10,15 +10,18 @@ * * @type {RulesProfile[]} * @description Defines possible rule profile sets: + * - amp: Amp Code integration * - claude: Claude Code integration * - cline: Cline IDE rules * - 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 * - windsurf: Windsurf IDE rules + * - zed: Zed IDE rules * * To add a new rule profile: * 1. Add the profile name to this array @@ -26,15 +29,18 @@ * 3. Export it as {profile}Profile in src/profiles/index.js */ export const RULE_PROFILES = [ + 'amp', 'claude', 'cline', 'codex', 'cursor', 'gemini', + 'opencode', 'roo', 'trae', 'vscode', - 'windsurf' + 'windsurf', + 'zed' ]; /** diff --git a/src/profiles/amp.js b/src/profiles/amp.js new file mode 100644 index 00000000..6c487c66 --- /dev/null +++ b/src/profiles/amp.js @@ -0,0 +1,277 @@ +// Amp profile for rule-transformer +import path from 'path'; +import fs from 'fs'; +import { isSilentMode, log } from '../../scripts/modules/utils.js'; +import { createProfile } from './base-profile.js'; + +/** + * Transform standard MCP config format to Amp format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed Amp configuration object + */ +function transformToAmpFormat(mcpConfig) { + const ampConfig = {}; + + // Transform mcpServers to amp.mcpServers + if (mcpConfig.mcpServers) { + ampConfig['amp.mcpServers'] = mcpConfig.mcpServers; + } + + // Preserve any other existing settings + for (const [key, value] of Object.entries(mcpConfig)) { + if (key !== 'mcpServers') { + ampConfig[key] = value; + } + } + + return ampConfig; +} + +// Lifecycle functions for Amp profile +function onAddRulesProfile(targetDir, assetsDir) { + // Handle AGENT.md import for non-destructive integration (Amp uses AGENT.md, copies from AGENTS.md) + const sourceFile = path.join(assetsDir, 'AGENTS.md'); + const userAgentFile = path.join(targetDir, 'AGENT.md'); + const taskMasterAgentFile = path.join(targetDir, '.taskmaster', 'AGENT.md'); + const importLine = '@./.taskmaster/AGENT.md'; + const importSection = `\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main AGENT.md file.**\n${importLine}`; + + if (fs.existsSync(sourceFile)) { + try { + // Ensure .taskmaster directory exists + const taskMasterDir = path.join(targetDir, '.taskmaster'); + if (!fs.existsSync(taskMasterDir)) { + fs.mkdirSync(taskMasterDir, { recursive: true }); + } + + // Copy Task Master instructions to .taskmaster/AGENT.md + fs.copyFileSync(sourceFile, taskMasterAgentFile); + log( + 'debug', + `[Amp] Created Task Master instructions at ${taskMasterAgentFile}` + ); + + // Handle user's AGENT.md + if (fs.existsSync(userAgentFile)) { + // Check if import already exists + const content = fs.readFileSync(userAgentFile, 'utf8'); + if (!content.includes(importLine)) { + // Append import section at the end + const updatedContent = content.trim() + '\n' + importSection + '\n'; + fs.writeFileSync(userAgentFile, updatedContent); + log( + 'info', + `[Amp] Added Task Master import to existing ${userAgentFile}` + ); + } else { + log( + 'info', + `[Amp] Task Master import already present in ${userAgentFile}` + ); + } + } else { + // Create minimal AGENT.md with the import section + const minimalContent = `# Amp Instructions\n${importSection}\n`; + fs.writeFileSync(userAgentFile, minimalContent); + log('info', `[Amp] Created ${userAgentFile} with Task Master import`); + } + } catch (err) { + log('error', `[Amp] Failed to set up Amp instructions: ${err.message}`); + } + } + + // MCP transformation will be handled in onPostConvertRulesProfile +} + +function onRemoveRulesProfile(targetDir) { + // Clean up AGENT.md import (Amp uses AGENT.md, not AGENTS.md) + const userAgentFile = path.join(targetDir, 'AGENT.md'); + const taskMasterAgentFile = path.join(targetDir, '.taskmaster', 'AGENT.md'); + const importLine = '@./.taskmaster/AGENT.md'; + + try { + // Remove Task Master AGENT.md from .taskmaster + if (fs.existsSync(taskMasterAgentFile)) { + fs.rmSync(taskMasterAgentFile, { force: true }); + log('debug', `[Amp] Removed ${taskMasterAgentFile}`); + } + + // Clean up import from user's AGENT.md + if (fs.existsSync(userAgentFile)) { + const content = fs.readFileSync(userAgentFile, 'utf8'); + const lines = content.split('\n'); + const filteredLines = []; + let skipNextLines = 0; + + // Remove the Task Master section + for (let i = 0; i < lines.length; i++) { + if (skipNextLines > 0) { + skipNextLines--; + continue; + } + + // Check if this is the start of our Task Master section + if (lines[i].includes('## Task Master AI Instructions')) { + // Skip this line and the next two lines (bold text and import) + skipNextLines = 2; + continue; + } + + // Also remove standalone import lines (for backward compatibility) + if (lines[i].trim() === importLine) { + continue; + } + + filteredLines.push(lines[i]); + } + + // Join back and clean up excessive newlines + let updatedContent = filteredLines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + // Check if file only contained our minimal template + if (updatedContent === '# Amp Instructions' || updatedContent === '') { + // File only contained our import, remove it + fs.rmSync(userAgentFile, { force: true }); + log('debug', `[Amp] Removed empty ${userAgentFile}`); + } else { + // Write back without the import + fs.writeFileSync(userAgentFile, updatedContent + '\n'); + log('debug', `[Amp] Removed Task Master import from ${userAgentFile}`); + } + } + } catch (err) { + log('error', `[Amp] Failed to remove Amp instructions: ${err.message}`); + } + + // MCP Removal: Remove amp.mcpServers section + const mcpConfigPath = path.join(targetDir, '.vscode', 'settings.json'); + + if (!fs.existsSync(mcpConfigPath)) { + log('debug', '[Amp] No .vscode/settings.json found to clean up'); + return; + } + + try { + // Read the current config + const configContent = fs.readFileSync(mcpConfigPath, 'utf8'); + const config = JSON.parse(configContent); + + // Check if it has the amp.mcpServers section and task-master-ai server + if ( + config['amp.mcpServers'] && + config['amp.mcpServers']['task-master-ai'] + ) { + // Remove task-master-ai server + delete config['amp.mcpServers']['task-master-ai']; + + // Check if there are other MCP servers in amp.mcpServers + const remainingServers = Object.keys(config['amp.mcpServers']); + + if (remainingServers.length === 0) { + // No other servers, remove entire amp.mcpServers section + delete config['amp.mcpServers']; + log('debug', '[Amp] Removed empty amp.mcpServers section'); + } + + // Check if config is now empty + const remainingKeys = Object.keys(config); + + if (remainingKeys.length === 0) { + // Config is empty, remove entire file + fs.rmSync(mcpConfigPath, { force: true }); + log('info', '[Amp] Removed empty settings.json file'); + + // Check if .vscode directory is empty + const vscodeDirPath = path.join(targetDir, '.vscode'); + if (fs.existsSync(vscodeDirPath)) { + const remainingContents = fs.readdirSync(vscodeDirPath); + if (remainingContents.length === 0) { + fs.rmSync(vscodeDirPath, { recursive: true, force: true }); + log('debug', '[Amp] Removed empty .vscode directory'); + } + } + } else { + // Write back the modified config + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(config, null, '\t') + '\n' + ); + log( + 'info', + '[Amp] Removed TaskMaster from settings.json, preserved other configurations' + ); + } + } else { + log('debug', '[Amp] TaskMaster not found in amp.mcpServers'); + } + } catch (error) { + log('error', `[Amp] Failed to clean up settings.json: ${error.message}`); + } +} + +function onPostConvertRulesProfile(targetDir, assetsDir) { + // Handle AGENT.md setup (same as onAddRulesProfile) + onAddRulesProfile(targetDir, assetsDir); + + // Transform MCP config to Amp format + const mcpConfigPath = path.join(targetDir, '.vscode', 'settings.json'); + + if (!fs.existsSync(mcpConfigPath)) { + log('debug', '[Amp] No .vscode/settings.json found to transform'); + return; + } + + try { + // Read the generated standard MCP config + const mcpConfigContent = fs.readFileSync(mcpConfigPath, 'utf8'); + const mcpConfig = JSON.parse(mcpConfigContent); + + // Check if it's already in Amp format (has amp.mcpServers) + if (mcpConfig['amp.mcpServers']) { + log( + 'info', + '[Amp] settings.json already in Amp format, skipping transformation' + ); + return; + } + + // Transform to Amp format + const ampConfig = transformToAmpFormat(mcpConfig); + + // Write back the transformed config with proper formatting + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(ampConfig, null, '\t') + '\n' + ); + + log('info', '[Amp] Transformed settings.json to Amp format'); + log('debug', '[Amp] Renamed mcpServers to amp.mcpServers'); + } catch (error) { + log('error', `[Amp] Failed to transform settings.json: ${error.message}`); + } +} + +// Create and export amp profile using the base factory +export const ampProfile = createProfile({ + name: 'amp', + displayName: 'Amp', + url: 'ampcode.com', + docsUrl: 'ampcode.com/manual', + profileDir: '.vscode', + rulesDir: '.', + mcpConfig: true, + mcpConfigName: 'settings.json', + includeDefaultRules: false, + fileMap: { + 'AGENTS.md': '.taskmaster/AGENT.md' + }, + onAdd: onAddRulesProfile, + onRemove: onRemoveRulesProfile, + onPostConvert: onPostConvertRulesProfile +}); + +// Export lifecycle functions separately to avoid naming conflicts +export { onAddRulesProfile, onRemoveRulesProfile, onPostConvertRulesProfile }; diff --git a/src/profiles/base-profile.js b/src/profiles/base-profile.js index 6f6add59..6f9c5e56 100644 --- a/src/profiles/base-profile.js +++ b/src/profiles/base-profile.js @@ -46,7 +46,9 @@ export function createProfile(editorConfig) { onPostConvert } = editorConfig; - const mcpConfigPath = mcpConfigName ? `${profileDir}/${mcpConfigName}` : null; + const mcpConfigPath = mcpConfigName + ? path.join(profileDir, mcpConfigName) + : null; // Standard file mapping with custom overrides // Use taskmaster subdirectory only if profile supports it diff --git a/src/profiles/claude.js b/src/profiles/claude.js index 2fc347f0..9790a2a8 100644 --- a/src/profiles/claude.js +++ b/src/profiles/claude.js @@ -197,9 +197,73 @@ function onRemoveRulesProfile(targetDir) { } } +/** + * Transform standard MCP config format to Claude format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed Claude configuration object + */ +function transformToClaudeFormat(mcpConfig) { + const claudeConfig = {}; + + // Transform mcpServers to servers (keeping the same structure but adding type) + if (mcpConfig.mcpServers) { + claudeConfig.mcpServers = {}; + + for (const [serverName, serverConfig] of Object.entries( + mcpConfig.mcpServers + )) { + // Transform server configuration with type as first key + const reorderedServer = {}; + + // Add type: "stdio" as the first key + reorderedServer.type = 'stdio'; + + // Then add the rest of the properties in order + if (serverConfig.command) reorderedServer.command = serverConfig.command; + if (serverConfig.args) reorderedServer.args = serverConfig.args; + if (serverConfig.env) reorderedServer.env = serverConfig.env; + + // Add any other properties that might exist + Object.keys(serverConfig).forEach((key) => { + if (!['command', 'args', 'env', 'type'].includes(key)) { + reorderedServer[key] = serverConfig[key]; + } + }); + + claudeConfig.mcpServers[serverName] = reorderedServer; + } + } + + return claudeConfig; +} + function onPostConvertRulesProfile(targetDir, assetsDir) { // For Claude, post-convert is the same as add since we don't transform rules onAddRulesProfile(targetDir, assetsDir); + + // Transform MCP configuration to Claude format + const mcpConfigPath = path.join(targetDir, '.mcp.json'); + if (fs.existsSync(mcpConfigPath)) { + try { + const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); + const claudeConfig = transformToClaudeFormat(mcpConfig); + + // Write back the transformed configuration + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(claudeConfig, null, '\t') + '\n' + ); + log( + 'debug', + `[Claude] Transformed MCP configuration to Claude format at ${mcpConfigPath}` + ); + } catch (err) { + log( + 'error', + `[Claude] Failed to transform MCP configuration: ${err.message}` + ); + } + } } // Create and export claude profile using the base factory @@ -210,8 +274,7 @@ export const claudeProfile = createProfile({ docsUrl: 'docs.anthropic.com/en/docs/claude-code', profileDir: '.', // Root directory rulesDir: '.', // No specific rules directory needed - mcpConfig: false, - mcpConfigName: null, + mcpConfigName: '.mcp.json', // Place MCP config in project root includeDefaultRules: false, fileMap: { 'AGENTS.md': '.taskmaster/CLAUDE.md' diff --git a/src/profiles/index.js b/src/profiles/index.js index 01b1b9fc..202f2663 100644 --- a/src/profiles/index.js +++ b/src/profiles/index.js @@ -1,10 +1,13 @@ // Profile exports for centralized importing +export { ampProfile } from './amp.js'; export { claudeProfile } from './claude.js'; 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'; export { windsurfProfile } from './windsurf.js'; +export { zedProfile } from './zed.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/profiles/zed.js b/src/profiles/zed.js new file mode 100644 index 00000000..989f7cd3 --- /dev/null +++ b/src/profiles/zed.js @@ -0,0 +1,178 @@ +// Zed profile for rule-transformer +import path from 'path'; +import fs from 'fs'; +import { isSilentMode, log } from '../../scripts/modules/utils.js'; +import { createProfile } from './base-profile.js'; + +/** + * Transform standard MCP config format to Zed format + * @param {Object} mcpConfig - Standard MCP configuration object + * @returns {Object} - Transformed Zed configuration object + */ +function transformToZedFormat(mcpConfig) { + const zedConfig = {}; + + // Transform mcpServers to context_servers + if (mcpConfig.mcpServers) { + zedConfig['context_servers'] = mcpConfig.mcpServers; + } + + // Preserve any other existing settings + for (const [key, value] of Object.entries(mcpConfig)) { + if (key !== 'mcpServers') { + zedConfig[key] = value; + } + } + + return zedConfig; +} + +// Lifecycle functions for Zed profile +function onAddRulesProfile(targetDir, assetsDir) { + // MCP transformation will be handled in onPostConvertRulesProfile + // File copying is handled by the base profile via fileMap +} + +function onRemoveRulesProfile(targetDir) { + // Clean up .rules (Zed uses .rules directly in root) + const userRulesFile = path.join(targetDir, '.rules'); + + try { + // Remove Task Master .rules + if (fs.existsSync(userRulesFile)) { + fs.rmSync(userRulesFile, { force: true }); + log('debug', `[Zed] Removed ${userRulesFile}`); + } + } catch (err) { + log('error', `[Zed] Failed to remove Zed instructions: ${err.message}`); + } + + // MCP Removal: Remove context_servers section + const mcpConfigPath = path.join(targetDir, '.zed', 'settings.json'); + + if (!fs.existsSync(mcpConfigPath)) { + log('debug', '[Zed] No .zed/settings.json found to clean up'); + return; + } + + try { + // Read the current config + const configContent = fs.readFileSync(mcpConfigPath, 'utf8'); + const config = JSON.parse(configContent); + + // Check if it has the context_servers section and task-master-ai server + if ( + config['context_servers'] && + config['context_servers']['task-master-ai'] + ) { + // Remove task-master-ai server + delete config['context_servers']['task-master-ai']; + + // Check if there are other MCP servers in context_servers + const remainingServers = Object.keys(config['context_servers']); + + if (remainingServers.length === 0) { + // No other servers, remove entire context_servers section + delete config['context_servers']; + log('debug', '[Zed] Removed empty context_servers section'); + } + + // Check if config is now empty + const remainingKeys = Object.keys(config); + + if (remainingKeys.length === 0) { + // Config is empty, remove entire file + fs.rmSync(mcpConfigPath, { force: true }); + log('info', '[Zed] Removed empty settings.json file'); + + // Check if .zed directory is empty + const zedDirPath = path.join(targetDir, '.zed'); + if (fs.existsSync(zedDirPath)) { + const remainingContents = fs.readdirSync(zedDirPath); + if (remainingContents.length === 0) { + fs.rmSync(zedDirPath, { recursive: true, force: true }); + log('debug', '[Zed] Removed empty .zed directory'); + } + } + } else { + // Write back the modified config + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(config, null, '\t') + '\n' + ); + log( + 'info', + '[Zed] Removed TaskMaster from settings.json, preserved other configurations' + ); + } + } else { + log('debug', '[Zed] TaskMaster not found in context_servers'); + } + } catch (error) { + log('error', `[Zed] Failed to clean up settings.json: ${error.message}`); + } +} + +function onPostConvertRulesProfile(targetDir, assetsDir) { + // Handle .rules setup (same as onAddRulesProfile) + onAddRulesProfile(targetDir, assetsDir); + + // Transform MCP config to Zed format + const mcpConfigPath = path.join(targetDir, '.zed', 'settings.json'); + + if (!fs.existsSync(mcpConfigPath)) { + log('debug', '[Zed] No .zed/settings.json found to transform'); + return; + } + + try { + // Read the generated standard MCP config + const mcpConfigContent = fs.readFileSync(mcpConfigPath, 'utf8'); + const mcpConfig = JSON.parse(mcpConfigContent); + + // Check if it's already in Zed format (has context_servers) + if (mcpConfig['context_servers']) { + log( + 'info', + '[Zed] settings.json already in Zed format, skipping transformation' + ); + return; + } + + // Transform to Zed format + const zedConfig = transformToZedFormat(mcpConfig); + + // Write back the transformed config with proper formatting + fs.writeFileSync( + mcpConfigPath, + JSON.stringify(zedConfig, null, '\t') + '\n' + ); + + log('info', '[Zed] Transformed settings.json to Zed format'); + log('debug', '[Zed] Renamed mcpServers to context_servers'); + } catch (error) { + log('error', `[Zed] Failed to transform settings.json: ${error.message}`); + } +} + +// Create and export zed profile using the base factory +export const zedProfile = createProfile({ + name: 'zed', + displayName: 'Zed', + url: 'zed.dev', + docsUrl: 'zed.dev/docs', + profileDir: '.zed', + rulesDir: '.', + mcpConfig: true, + mcpConfigName: 'settings.json', + includeDefaultRules: false, + fileMap: { + 'AGENTS.md': '.rules' + }, + onAdd: onAddRulesProfile, + onRemove: onRemoveRulesProfile, + onPostConvert: onPostConvertRulesProfile +}); + +// Export lifecycle functions separately to avoid naming conflicts +export { onAddRulesProfile, onRemoveRulesProfile, onPostConvertRulesProfile }; diff --git a/src/utils/profiles.js b/src/utils/profiles.js index 32a2b7cf..567ee9ec 100644 --- a/src/utils/profiles.js +++ b/src/utils/profiles.js @@ -113,12 +113,12 @@ export async function runInteractiveProfilesSetup() { const hasMcpConfig = profile.mcpConfig === true; if (!profile.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini) - 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') { + } else if (hasMcpConfig) { description = 'Integration guide and MCP config'; } else { description = 'Integration guide'; @@ -199,7 +199,7 @@ export function generateProfileSummary(profileName, addResult) { const profileConfig = getRulesProfile(profileName); if (!profileConfig.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini) + // Integration guide profiles (claude, codex, gemini, amp) return `Summary for ${profileName}: Integration guide installed.`; } else { // Rule profiles with coding guidelines @@ -225,7 +225,7 @@ export function generateProfileRemovalSummary(profileName, removeResult) { const profileConfig = getRulesProfile(profileName); if (!profileConfig.includeDefaultRules) { - // Integration guide profiles (claude, codex, gemini) + // Integration guide profiles (claude, codex, gemini, amp) const baseMessage = `Summary for ${profileName}: Integration guide removed`; if (removeResult.notice) { return `${baseMessage} (${removeResult.notice})`; diff --git a/tests/integration/profiles/amp-init-functionality.test.js b/tests/integration/profiles/amp-init-functionality.test.js new file mode 100644 index 00000000..dcf862b6 --- /dev/null +++ b/tests/integration/profiles/amp-init-functionality.test.js @@ -0,0 +1,346 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; +import { convertAllRulesToProfileRules } from '../../../src/utils/rule-transformer.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('Amp Profile Init Functionality', () => { + let tempDir; + let ampProfile; + + beforeEach(() => { + // Create temporary directory for testing + tempDir = fs.mkdtempSync(path.join(__dirname, 'temp-amp-')); + + // Get the Amp profile + ampProfile = getRulesProfile('amp'); + }); + + afterEach(() => { + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('Profile Configuration', () => { + test('should have correct profile metadata', () => { + expect(ampProfile).toBeDefined(); + expect(ampProfile.profileName).toBe('amp'); + expect(ampProfile.displayName).toBe('Amp'); + expect(ampProfile.profileDir).toBe('.vscode'); + expect(ampProfile.rulesDir).toBe('.'); + expect(ampProfile.mcpConfig).toBe(true); + expect(ampProfile.mcpConfigName).toBe('settings.json'); + expect(ampProfile.mcpConfigPath).toBe('.vscode/settings.json'); + expect(ampProfile.includeDefaultRules).toBe(false); + }); + + test('should have correct file mapping', () => { + expect(ampProfile.fileMap).toBeDefined(); + expect(ampProfile.fileMap['AGENTS.md']).toBe('.taskmaster/AGENT.md'); + }); + + test('should have lifecycle functions', () => { + expect(typeof ampProfile.onAddRulesProfile).toBe('function'); + expect(typeof ampProfile.onRemoveRulesProfile).toBe('function'); + expect(typeof ampProfile.onPostConvertRulesProfile).toBe('function'); + }); + }); + + describe('AGENT.md Handling', () => { + test('should create AGENT.md with import when none exists', () => { + // Create mock AGENTS.md source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Check that AGENT.md was created with import + const agentFile = path.join(tempDir, 'AGENT.md'); + expect(fs.existsSync(agentFile)).toBe(true); + + const content = fs.readFileSync(agentFile, 'utf8'); + expect(content).toContain('# Amp Instructions'); + expect(content).toContain('## Task Master AI Instructions'); + expect(content).toContain('@./.taskmaster/AGENT.md'); + + // Check that .taskmaster/AGENT.md was created + const taskMasterAgent = path.join(tempDir, '.taskmaster', 'AGENT.md'); + expect(fs.existsSync(taskMasterAgent)).toBe(true); + }); + + test('should append import to existing AGENT.md', () => { + // Create existing AGENT.md + const existingContent = + '# My Existing Amp Instructions\n\nSome content here.'; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), existingContent); + + // Create mock AGENTS.md source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Check that import was appended + const agentFile = path.join(tempDir, 'AGENT.md'); + const content = fs.readFileSync(agentFile, 'utf8'); + expect(content).toContain('# My Existing Amp Instructions'); + expect(content).toContain('Some content here.'); + expect(content).toContain('## Task Master AI Instructions'); + expect(content).toContain('@./.taskmaster/AGENT.md'); + }); + + test('should not duplicate import if already exists', () => { + // Create AGENT.md with existing import + const existingContent = + "# My Amp Instructions\n\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main AGENT.md file.**\n@./.taskmaster/AGENT.md"; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), existingContent); + + // Create mock AGENTS.md source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Check that import was not duplicated + const agentFile = path.join(tempDir, 'AGENT.md'); + const content = fs.readFileSync(agentFile, 'utf8'); + const importCount = (content.match(/@\.\/.taskmaster\/AGENT\.md/g) || []) + .length; + expect(importCount).toBe(1); + }); + }); + + describe('MCP Configuration', () => { + test('should rename mcpServers to amp.mcpServers', () => { + // Create .vscode directory and settings.json with mcpServers + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + } + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onPostConvertRulesProfile (which should transform mcpServers to amp.mcpServers) + ampProfile.onPostConvertRulesProfile( + tempDir, + path.join(tempDir, 'assets') + ); + + // Check that mcpServers was renamed to amp.mcpServers + const settingsFile = path.join(vscodeDirPath, 'settings.json'); + const content = fs.readFileSync(settingsFile, 'utf8'); + const config = JSON.parse(content); + + expect(config.mcpServers).toBeUndefined(); + expect(config['amp.mcpServers']).toBeDefined(); + expect(config['amp.mcpServers']['task-master-ai']).toBeDefined(); + }); + + test('should not rename if amp.mcpServers already exists', () => { + // Create .vscode directory and settings.json with both mcpServers and amp.mcpServers + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + mcpServers: { + 'some-other-server': { + command: 'other-command' + } + }, + 'amp.mcpServers': { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + } + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, path.join(tempDir, 'assets')); + + // Check that both sections remain unchanged + const settingsFile = path.join(vscodeDirPath, 'settings.json'); + const content = fs.readFileSync(settingsFile, 'utf8'); + const config = JSON.parse(content); + + expect(config.mcpServers).toBeDefined(); + expect(config.mcpServers['some-other-server']).toBeDefined(); + expect(config['amp.mcpServers']).toBeDefined(); + expect(config['amp.mcpServers']['task-master-ai']).toBeDefined(); + }); + }); + + describe('Removal Functionality', () => { + test('should remove AGENT.md import and clean up files', () => { + // Setup: Create AGENT.md with import and .taskmaster/AGENT.md + const agentContent = + "# My Amp Instructions\n\nSome content.\n\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main AGENT.md file.**\n@./.taskmaster/AGENT.md\n"; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), agentContent); + + fs.mkdirSync(path.join(tempDir, '.taskmaster'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, '.taskmaster', 'AGENT.md'), + 'Task Master instructions' + ); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that .taskmaster/AGENT.md was removed + expect(fs.existsSync(path.join(tempDir, '.taskmaster', 'AGENT.md'))).toBe( + false + ); + + // Check that import was removed from AGENT.md + const remainingContent = fs.readFileSync( + path.join(tempDir, 'AGENT.md'), + 'utf8' + ); + expect(remainingContent).not.toContain('## Task Master AI Instructions'); + expect(remainingContent).not.toContain('@./.taskmaster/AGENT.md'); + expect(remainingContent).toContain('# My Amp Instructions'); + expect(remainingContent).toContain('Some content.'); + }); + + test('should remove empty AGENT.md if only contained import', () => { + // Setup: Create AGENT.md with only import + const agentContent = + "# Amp Instructions\n\n## Task Master AI Instructions\n**Import Task Master's development workflow commands and guidelines, treat as if import is in the main AGENT.md file.**\n@./.taskmaster/AGENT.md"; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), agentContent); + + fs.mkdirSync(path.join(tempDir, '.taskmaster'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, '.taskmaster', 'AGENT.md'), + 'Task Master instructions' + ); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that AGENT.md was removed + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(false); + }); + + test('should remove amp.mcpServers section from settings.json', () => { + // Setup: Create .vscode/settings.json with amp.mcpServers and other settings + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + 'amp.mcpServers': { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + }, + 'other.setting': 'value' + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that amp.mcpServers was removed but other settings remain + const settingsFile = path.join(vscodeDirPath, 'settings.json'); + expect(fs.existsSync(settingsFile)).toBe(true); + + const content = fs.readFileSync(settingsFile, 'utf8'); + const config = JSON.parse(content); + + expect(config['amp.mcpServers']).toBeUndefined(); + expect(config['other.setting']).toBe('value'); + }); + + test('should remove settings.json and .vscode directory if empty after removal', () => { + // Setup: Create .vscode/settings.json with only amp.mcpServers + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + 'amp.mcpServers': { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + } + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that settings.json and .vscode directory were removed + expect(fs.existsSync(path.join(vscodeDirPath, 'settings.json'))).toBe( + false + ); + expect(fs.existsSync(vscodeDirPath)).toBe(false); + }); + }); + + describe('Full Integration', () => { + test('should work with convertAllRulesToProfileRules', () => { + // This test ensures the profile works with the full rule transformer + const result = convertAllRulesToProfileRules(tempDir, ampProfile); + + expect(result.success).toBeGreaterThan(0); + expect(result.failed).toBe(0); + + // Check that .taskmaster/AGENT.md was created + expect(fs.existsSync(path.join(tempDir, '.taskmaster', 'AGENT.md'))).toBe( + true + ); + + // Check that AGENT.md was created with import + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(true); + const agentContent = fs.readFileSync( + path.join(tempDir, 'AGENT.md'), + 'utf8' + ); + expect(agentContent).toContain('@./.taskmaster/AGENT.md'); + }); + }); +}); diff --git a/tests/integration/profiles/claude-init-functionality.test.js b/tests/integration/profiles/claude-init-functionality.test.js index ed623630..7ae49dc3 100644 --- a/tests/integration/profiles/claude-init-functionality.test.js +++ b/tests/integration/profiles/claude-init-functionality.test.js @@ -21,7 +21,7 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfileContent).toContain("displayName: 'Claude Code'"); expect(claudeProfileContent).toContain("profileDir: '.'"); // non-default expect(claudeProfileContent).toContain("rulesDir: '.'"); // non-default - expect(claudeProfileContent).toContain('mcpConfig: false'); // non-default + expect(claudeProfileContent).toContain("mcpConfigName: '.mcp.json'"); // non-default expect(claudeProfileContent).toContain('includeDefaultRules: false'); // non-default expect(claudeProfileContent).toContain( "'AGENTS.md': '.taskmaster/CLAUDE.md'" @@ -32,8 +32,9 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfile.displayName).toBe('Claude Code'); expect(claudeProfile.profileDir).toBe('.'); expect(claudeProfile.rulesDir).toBe('.'); - expect(claudeProfile.mcpConfig).toBe(false); - expect(claudeProfile.mcpConfigName).toBe(null); // computed + expect(claudeProfile.mcpConfig).toBe(true); // default from base profile + expect(claudeProfile.mcpConfigName).toBe('.mcp.json'); // explicitly set + expect(claudeProfile.mcpConfigPath).toBe('.mcp.json'); // computed expect(claudeProfile.includeDefaultRules).toBe(false); expect(claudeProfile.fileMap['AGENTS.md']).toBe('.taskmaster/CLAUDE.md'); }); 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/ai-services-unified.test.js b/tests/unit/ai-services-unified.test.js index 3759333a..bbbe65c4 100644 --- a/tests/unit/ai-services-unified.test.js +++ b/tests/unit/ai-services-unified.test.js @@ -177,6 +177,13 @@ jest.unstable_mockModule('../../src/ai-providers/index.js', () => ({ getRequiredApiKeyName: jest.fn(() => 'XAI_API_KEY'), isRequiredApiKey: jest.fn(() => true) })), + GroqProvider: jest.fn(() => ({ + generateText: jest.fn(), + streamText: jest.fn(), + generateObject: jest.fn(), + getRequiredApiKeyName: jest.fn(() => 'GROQ_API_KEY'), + isRequiredApiKey: jest.fn(() => true) + })), OpenRouterAIProvider: jest.fn(() => ({ generateText: jest.fn(), streamText: jest.fn(), diff --git a/tests/unit/profiles/amp-integration.test.js b/tests/unit/profiles/amp-integration.test.js new file mode 100644 index 00000000..53eff784 --- /dev/null +++ b/tests/unit/profiles/amp-integration.test.js @@ -0,0 +1,299 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('Amp Profile Integration', () => { + let tempDir; + let ampProfile; + + beforeEach(() => { + // Create temporary directory for testing + tempDir = fs.mkdtempSync(path.join(__dirname, 'temp-amp-unit-')); + + // Get the Amp profile + ampProfile = getRulesProfile('amp'); + }); + + afterEach(() => { + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('Profile Structure', () => { + test('should have expected profile structure', () => { + expect(ampProfile).toBeDefined(); + expect(ampProfile.profileName).toBe('amp'); + expect(ampProfile.displayName).toBe('Amp'); + expect(ampProfile.profileDir).toBe('.vscode'); + expect(ampProfile.rulesDir).toBe('.'); + expect(ampProfile.mcpConfig).toBe(true); + expect(ampProfile.mcpConfigName).toBe('settings.json'); + expect(ampProfile.mcpConfigPath).toBe('.vscode/settings.json'); + expect(ampProfile.includeDefaultRules).toBe(false); + }); + + test('should have correct file mapping', () => { + expect(ampProfile.fileMap).toEqual({ + 'AGENTS.md': '.taskmaster/AGENT.md' + }); + }); + + test('should not create unnecessary directories', () => { + // Unlike profiles that copy entire directories, Amp should only create what's needed + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Should only have created .taskmaster directory and AGENT.md + expect(fs.existsSync(path.join(tempDir, '.taskmaster'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(true); + + // Should not have created any other directories (like .claude) + expect(fs.existsSync(path.join(tempDir, '.amp'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, '.claude'))).toBe(false); + }); + }); + + describe('AGENT.md Import Logic', () => { + test('should handle missing source file gracefully', () => { + // Call onAddRulesProfile without creating source file + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + + // Should not throw error + expect(() => { + ampProfile.onAddRulesProfile(tempDir, assetsDir); + }).not.toThrow(); + + // Should not create any files + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, '.taskmaster', 'AGENT.md'))).toBe( + false + ); + }); + + test('should preserve existing content when adding import', () => { + // Create existing AGENT.md with specific content + const existingContent = + '# My Custom Amp Setup\n\nThis is my custom configuration.\n\n## Custom Section\n\nSome custom rules here.'; + fs.writeFileSync(path.join(tempDir, 'AGENT.md'), existingContent); + + // Create mock source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onAddRulesProfile + ampProfile.onAddRulesProfile(tempDir, assetsDir); + + // Check that existing content is preserved + const updatedContent = fs.readFileSync( + path.join(tempDir, 'AGENT.md'), + 'utf8' + ); + expect(updatedContent).toContain('# My Custom Amp Setup'); + expect(updatedContent).toContain('This is my custom configuration.'); + expect(updatedContent).toContain('## Custom Section'); + expect(updatedContent).toContain('Some custom rules here.'); + expect(updatedContent).toContain('@./.taskmaster/AGENT.md'); + }); + }); + + describe('MCP Configuration Handling', () => { + test('should handle missing .vscode directory gracefully', () => { + // Call onAddRulesProfile without .vscode directory + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + + // Should not throw error + expect(() => { + ampProfile.onAddRulesProfile(tempDir, assetsDir); + }).not.toThrow(); + }); + + test('should handle malformed JSON gracefully', () => { + // Create .vscode directory with malformed JSON + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + '{ malformed json' + ); + + // Should not throw error + expect(() => { + ampProfile.onAddRulesProfile(tempDir, path.join(tempDir, 'assets')); + }).not.toThrow(); + }); + + test('should preserve other VS Code settings when renaming', () => { + // Create .vscode/settings.json with various settings + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + 'editor.fontSize': 14, + 'editor.tabSize': 2, + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + }, + 'workbench.colorTheme': 'Dark+' + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Call onPostConvertRulesProfile (which handles MCP transformation) + ampProfile.onPostConvertRulesProfile( + tempDir, + path.join(tempDir, 'assets') + ); + + // Check that other settings are preserved + const settingsFile = path.join(vscodeDirPath, 'settings.json'); + const content = fs.readFileSync(settingsFile, 'utf8'); + const config = JSON.parse(content); + + expect(config['editor.fontSize']).toBe(14); + expect(config['editor.tabSize']).toBe(2); + expect(config['workbench.colorTheme']).toBe('Dark+'); + expect(config['amp.mcpServers']).toBeDefined(); + expect(config.mcpServers).toBeUndefined(); + }); + }); + + describe('Removal Logic', () => { + test('should handle missing files gracefully during removal', () => { + // Should not throw error when removing non-existent files + expect(() => { + ampProfile.onRemoveRulesProfile(tempDir); + }).not.toThrow(); + }); + + test('should handle malformed JSON gracefully during removal', () => { + // Create .vscode directory with malformed JSON + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + '{ malformed json' + ); + + // Should not throw error + expect(() => { + ampProfile.onRemoveRulesProfile(tempDir); + }).not.toThrow(); + }); + + test('should preserve .vscode directory if it contains other files', () => { + // Create .vscode directory with amp.mcpServers and other files + const vscodeDirPath = path.join(tempDir, '.vscode'); + fs.mkdirSync(vscodeDirPath, { recursive: true }); + + const initialConfig = { + 'amp.mcpServers': { + 'task-master-ai': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + } + } + }; + + fs.writeFileSync( + path.join(vscodeDirPath, 'settings.json'), + JSON.stringify(initialConfig, null, '\t') + ); + + // Create another file in .vscode + fs.writeFileSync(path.join(vscodeDirPath, 'launch.json'), '{}'); + + // Call onRemoveRulesProfile + ampProfile.onRemoveRulesProfile(tempDir); + + // Check that .vscode directory is preserved + expect(fs.existsSync(vscodeDirPath)).toBe(true); + expect(fs.existsSync(path.join(vscodeDirPath, 'launch.json'))).toBe(true); + }); + }); + + describe('Lifecycle Function Integration', () => { + test('should have all required lifecycle functions', () => { + expect(typeof ampProfile.onAddRulesProfile).toBe('function'); + expect(typeof ampProfile.onRemoveRulesProfile).toBe('function'); + expect(typeof ampProfile.onPostConvertRulesProfile).toBe('function'); + }); + + test('onPostConvertRulesProfile should behave like onAddRulesProfile', () => { + // Create mock source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Call onPostConvertRulesProfile + ampProfile.onPostConvertRulesProfile(tempDir, assetsDir); + + // Should have same result as onAddRulesProfile + expect(fs.existsSync(path.join(tempDir, '.taskmaster', 'AGENT.md'))).toBe( + true + ); + expect(fs.existsSync(path.join(tempDir, 'AGENT.md'))).toBe(true); + + const agentContent = fs.readFileSync( + path.join(tempDir, 'AGENT.md'), + 'utf8' + ); + expect(agentContent).toContain('@./.taskmaster/AGENT.md'); + }); + }); + + describe('Error Handling', () => { + test('should handle file system errors gracefully', () => { + // Mock fs.writeFileSync to throw an error + const originalWriteFileSync = fs.writeFileSync; + fs.writeFileSync = jest.fn().mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Create mock source + const assetsDir = path.join(tempDir, 'assets'); + fs.mkdirSync(assetsDir, { recursive: true }); + originalWriteFileSync.call( + fs, + path.join(assetsDir, 'AGENTS.md'), + 'Task Master instructions' + ); + + // Should not throw error + expect(() => { + ampProfile.onAddRulesProfile(tempDir, assetsDir); + }).not.toThrow(); + + // Restore original function + fs.writeFileSync = originalWriteFileSync; + }); + }); +}); diff --git a/tests/unit/profiles/claude-integration.test.js b/tests/unit/profiles/claude-integration.test.js index 4fe723a8..900468e3 100644 --- a/tests/unit/profiles/claude-integration.test.js +++ b/tests/unit/profiles/claude-integration.test.js @@ -2,6 +2,7 @@ import { jest } from '@jest/globals'; import fs from 'fs'; import path from 'path'; import os from 'os'; +import { claudeProfile } from '../../../src/profiles/claude.js'; // Mock external modules jest.mock('child_process', () => ({ @@ -77,11 +78,22 @@ describe('Claude Profile Integration', () => { expect(mkdirCalls).toHaveLength(0); }); - test('does not create MCP configuration files', () => { + test('supports MCP configuration when using rule transformer', () => { + // This test verifies that the Claude profile is configured to support MCP + // The actual MCP file creation is handled by the rule transformer + + // Assert - Claude profile should now support MCP configuration + expect(claudeProfile.mcpConfig).toBe(true); + expect(claudeProfile.mcpConfigName).toBe('.mcp.json'); + expect(claudeProfile.mcpConfigPath).toBe('.mcp.json'); + }); + + test('mock function does not create MCP configuration files', () => { // Act mockCreateClaudeStructure(); - // Assert - Claude profile should not create any MCP config files + // Assert - The mock function should not create MCP config files + // (This is expected since the mock doesn't use the rule transformer) const writeFileCalls = fs.writeFileSync.mock.calls; const mcpConfigCalls = writeFileCalls.filter( (call) => diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index 9397ae9f..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', @@ -46,6 +70,12 @@ describe('MCP Configuration Validation', () => { expectedDir: '.windsurf', expectedConfigName: 'mcp.json', expectedPath: '.windsurf/mcp.json' + }, + zed: { + shouldHaveMcp: true, + expectedDir: '.zed', + expectedConfigName: 'settings.json', + expectedPath: '.zed/settings.json' } }; @@ -68,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); } }); @@ -89,10 +127,23 @@ 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) { - expect(profile.mcpConfigPath).toMatch(/^\.[\w-]+\/[\w_.]+$/); + 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_.]+$/); + } } }); }); @@ -123,17 +174,13 @@ describe('MCP Configuration Validation', () => { }); test('should have null config name for non-MCP profiles', () => { - const clineProfile = getRulesProfile('cline'); - expect(clineProfile.mcpConfigName).toBe(null); + // Only codex, cline, and trae profiles should have null config names + const nonMcpProfiles = ['codex', 'cline', 'trae']; - const traeProfile = getRulesProfile('trae'); - expect(traeProfile.mcpConfigName).toBe(null); - - const claudeProfile = getRulesProfile('claude'); - expect(claudeProfile.mcpConfigName).toBe(null); - - const codexProfile = getRulesProfile('codex'); - expect(codexProfile.mcpConfigName).toBe(null); + for (const profileName of nonMcpProfiles) { + const profile = getRulesProfile(profileName); + expect(profile.mcpConfigName).toBe(null); + } }); }); @@ -141,7 +188,9 @@ 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 RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); @@ -151,17 +200,25 @@ describe('MCP Configuration Validation', () => { expect(profile.rulesDir).toBe('.'); } - // Profile directories should be unique (except for root profiles) - if (!rootProfiles.includes(profileName) || profile.profileDir !== '.') { - expect(profileDirs.has(profile.profileDir)).toBe(false); - profileDirs.add(profile.profileDir); + // Profile directories should be unique (except for root profiles and shared directory profiles) + if ( + !rootProfiles.includes(profileName) && + !sharedDirectoryProfiles.includes(profileName) + ) { + if (profile.profileDir !== '.') { + expect(profileDirs.has(profile.profileDir)).toBe(false); + profileDirs.add(profile.profileDir); + } + } else if (sharedDirectoryProfiles.includes(profileName)) { + // Shared directory profiles should use .vscode + expect(profile.profileDir).toBe('.vscode'); } }); }); 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); @@ -185,17 +242,22 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Creation Logic', () => { test('should indicate which profiles require MCP configuration creation', () => { + // Get all profiles that have MCP configuration enabled const mcpEnabledProfiles = RULE_PROFILES.filter((profileName) => { const profile = getRulesProfile(profileName); return profile.mcpConfig !== false; }); + // 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).not.toContain('claude'); + expect(mcpEnabledProfiles).toContain('zed'); expect(mcpEnabledProfiles).not.toContain('cline'); expect(mcpEnabledProfiles).not.toContain('codex'); expect(mcpEnabledProfiles).not.toContain('trae'); @@ -215,17 +277,36 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Path Usage Verification', () => { test('should verify that rule transformer functions use mcpConfigPath correctly', () => { - // This test verifies that the mcpConfigPath property exists and is properly formatted - // for use with the setupMCPConfiguration function RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { // Verify the path is properly formatted for path.join usage expect(profile.mcpConfigPath.startsWith('/')).toBe(false); - expect(profile.mcpConfigPath).toContain('/'); - // Verify it matches the expected pattern: profileDir/configName - const expectedPath = `${profile.profileDir}/${profile.mcpConfigName}`; + // 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 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); } }); @@ -240,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); } }); @@ -250,28 +335,45 @@ describe('MCP Configuration Validation', () => { describe('MCP Configuration Function Integration', () => { test('should verify that setupMCPConfiguration receives the correct mcpConfigPath parameter', () => { - // This test verifies the integration between rule transformer and mcp-utils RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); if (profile.mcpConfig !== false) { - // Verify that the mcpConfigPath can be used directly with setupMCPConfiguration - // The function signature is: setupMCPConfiguration(projectDir, mcpConfigPath) - expect(profile.mcpConfigPath).toBeDefined(); - expect(typeof profile.mcpConfigPath).toBe('string'); - // Verify the path structure is correct for the new function signature - const parts = profile.mcpConfigPath.split('/'); - expect(parts).toHaveLength(2); // Should be profileDir/configName - expect(parts[0]).toBe(profile.profileDir); - expect(parts[1]).toBe(profile.mcpConfigName); + 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); + expect(parts[1]).toBe(profile.mcpConfigName); + } } }); }); }); describe('MCP configuration validation', () => { - const mcpProfiles = ['cursor', 'gemini', 'roo', 'windsurf', 'vscode']; - const nonMcpProfiles = ['claude', 'codex', 'cline', 'trae']; + const mcpProfiles = [ + 'amp', + 'claude', + 'cursor', + 'gemini', + 'opencode', + 'roo', + 'windsurf', + 'vscode', + 'zed' + ]; + const nonMcpProfiles = ['codex', 'cline', 'trae']; + const profilesWithLifecycle = ['claude']; + const profilesWithoutLifecycle = ['codex']; test.each(mcpProfiles)( 'should have valid MCP config for %s profile', @@ -295,19 +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 = ['claude']; + 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); @@ -333,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-zed.test.js b/tests/unit/profiles/rule-transformer-zed.test.js new file mode 100644 index 00000000..55dc4801 --- /dev/null +++ b/tests/unit/profiles/rule-transformer-zed.test.js @@ -0,0 +1,212 @@ +import { jest } from '@jest/globals'; + +// Mock fs module before importing anything that uses it +jest.mock('fs', () => ({ + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + existsSync: jest.fn(), + mkdirSync: jest.fn() +})); + +// Import modules after mocking +import fs from 'fs'; +import { convertRuleToProfileRule } from '../../../src/utils/rule-transformer.js'; +import { zedProfile } from '../../../src/profiles/zed.js'; + +describe('Zed Rule Transformer', () => { + // Set up spies on the mocked modules + const mockReadFileSync = jest.spyOn(fs, 'readFileSync'); + const mockWriteFileSync = jest.spyOn(fs, 'writeFileSync'); + const mockExistsSync = jest.spyOn(fs, 'existsSync'); + const mockMkdirSync = jest.spyOn(fs, 'mkdirSync'); + const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + beforeEach(() => { + jest.clearAllMocks(); + // Setup default mocks + mockReadFileSync.mockReturnValue(''); + mockWriteFileSync.mockImplementation(() => {}); + mockExistsSync.mockReturnValue(true); + mockMkdirSync.mockImplementation(() => {}); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should correctly convert basic terms', () => { + const testContent = `--- +description: Test Cursor rule for basic terms +globs: **/* +alwaysApply: true +--- + +This is a Cursor rule that references cursor.so and uses the word Cursor multiple times. +Also has references to .mdc files.`; + + // Mock file read to return our test content + mockReadFileSync.mockReturnValue(testContent); + + // Mock file system operations + mockExistsSync.mockReturnValue(true); + + // Call the function + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + // Verify the result + expect(result).toBe(true); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + + // Get the transformed content + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify Cursor -> Zed transformations + expect(transformedContent).toContain('zed.dev'); + expect(transformedContent).toContain('Zed'); + expect(transformedContent).not.toContain('cursor.so'); + expect(transformedContent).not.toContain('Cursor'); + expect(transformedContent).toContain('.md'); + expect(transformedContent).not.toContain('.mdc'); + }); + + it('should handle URL transformations', () => { + const testContent = `Visit https://cursor.so/docs for more information. +Also check out cursor.so and www.cursor.so for updates.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify URL transformations + expect(transformedContent).toContain('https://zed.dev'); + expect(transformedContent).toContain('zed.dev'); + expect(transformedContent).not.toContain('cursor.so'); + }); + + it('should handle file extension transformations', () => { + const testContent = `This rule references file.mdc and another.mdc file. +Use the .mdc extension for all rule files.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify file extension transformations + expect(transformedContent).toContain('file.md'); + expect(transformedContent).toContain('another.md'); + expect(transformedContent).toContain('.md extension'); + expect(transformedContent).not.toContain('.mdc'); + }); + + it('should handle case variations', () => { + const testContent = `CURSOR, Cursor, cursor should all be transformed.`; + + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(true); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(true); + const transformedContent = mockWriteFileSync.mock.calls[0][1]; + + // Verify case transformations + // Due to regex order, the case-insensitive rule runs first: + // CURSOR -> Zed (because it starts with 'C'), Cursor -> Zed, cursor -> zed + expect(transformedContent).toContain('Zed'); + expect(transformedContent).toContain('zed'); + expect(transformedContent).not.toContain('CURSOR'); + expect(transformedContent).not.toContain('Cursor'); + expect(transformedContent).not.toContain('cursor'); + }); + + it('should create target directory if it does not exist', () => { + const testContent = 'Test content'; + mockReadFileSync.mockReturnValue(testContent); + mockExistsSync.mockReturnValue(false); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'nested/path/test-target.md', + zedProfile + ); + + expect(result).toBe(true); + expect(mockMkdirSync).toHaveBeenCalledWith('nested/path', { + recursive: true + }); + }); + + it('should handle file system errors gracefully', () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(false); + expect(mockConsoleError).toHaveBeenCalledWith( + 'Error converting rule file: File not found' + ); + }); + + it('should handle write errors gracefully', () => { + mockReadFileSync.mockReturnValue('Test content'); + mockWriteFileSync.mockImplementation(() => { + throw new Error('Write permission denied'); + }); + + const result = convertRuleToProfileRule( + 'test-source.mdc', + 'test-target.md', + zedProfile + ); + + expect(result).toBe(false); + expect(mockConsoleError).toHaveBeenCalledWith( + 'Error converting rule file: Write permission denied' + ); + }); + + it('should verify profile configuration', () => { + expect(zedProfile.profileName).toBe('zed'); + expect(zedProfile.displayName).toBe('Zed'); + expect(zedProfile.profileDir).toBe('.zed'); + expect(zedProfile.mcpConfig).toBe(true); + expect(zedProfile.mcpConfigName).toBe('settings.json'); + expect(zedProfile.mcpConfigPath).toBe('.zed/settings.json'); + expect(zedProfile.includeDefaultRules).toBe(false); + expect(zedProfile.fileMap).toEqual({ + 'AGENTS.md': '.rules' + }); + }); +}); diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index 7950d738..c93f957c 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -3,6 +3,7 @@ import { getRulesProfile } from '../../../src/utils/rule-transformer.js'; import { RULE_PROFILES } from '../../../src/constants/profiles.js'; +import path from 'path'; describe('Rule Transformer - General', () => { describe('Profile Configuration Validation', () => { @@ -18,10 +19,12 @@ describe('Rule Transformer - General', () => { 'codex', 'cursor', 'gemini', + 'opencode', 'roo', 'trae', 'vscode', - 'windsurf' + 'windsurf', + 'zed' ]; expectedProfiles.forEach((profile) => { expect(RULE_PROFILES).toContain(profile); @@ -166,29 +169,28 @@ describe('Rule Transformer - General', () => { // Check types based on MCP configuration expect(typeof profileConfig.mcpConfig).toBe('boolean'); - if (profileConfig.mcpConfig === false) { - // Profiles without MCP configuration - expect(profileConfig.mcpConfigName).toBe(null); - expect(profileConfig.mcpConfigPath).toBe(null); - } else { - // Profiles with MCP configuration - expect(typeof profileConfig.mcpConfigName).toBe('string'); - expect(typeof profileConfig.mcpConfigPath).toBe('string'); - + if (profileConfig.mcpConfig !== false) { // Check that mcpConfigPath is properly constructed - expect(profileConfig.mcpConfigPath).toBe( - `${profileConfig.profileDir}/${profileConfig.mcpConfigName}` + const expectedPath = path.join( + profileConfig.profileDir, + profileConfig.mcpConfigName ); + expect(profileConfig.mcpConfigPath).toBe(expectedPath); } }); }); it('should have correct MCP configuration for each profile', () => { const expectedConfigs = { + amp: { + mcpConfig: true, + mcpConfigName: 'settings.json', + expectedPath: '.vscode/settings.json' + }, claude: { - mcpConfig: false, - mcpConfigName: null, - expectedPath: null + mcpConfig: true, + mcpConfigName: '.mcp.json', + expectedPath: '.mcp.json' }, cline: { mcpConfig: false, @@ -210,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', @@ -229,6 +236,11 @@ describe('Rule Transformer - General', () => { mcpConfig: true, mcpConfigName: 'mcp.json', expectedPath: '.windsurf/mcp.json' + }, + zed: { + mcpConfig: true, + mcpConfigName: 'settings.json', + expectedPath: '.zed/settings.json' } }; @@ -245,25 +257,27 @@ describe('Rule Transformer - General', () => { it('should have consistent profileDir and mcpConfigPath relationship', () => { RULE_PROFILES.forEach((profile) => { const profileConfig = getRulesProfile(profile); - - if (profileConfig.mcpConfig === false) { - // Profiles without MCP configuration have null mcpConfigPath - expect(profileConfig.mcpConfigPath).toBe(null); - } else { + if (profileConfig.mcpConfig !== false) { // Profiles with MCP configuration should have valid paths - // The mcpConfigPath should start with the profileDir - expect(profileConfig.mcpConfigPath).toMatch( - new RegExp( - `^${profileConfig.profileDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/` - ) - ); - - // The mcpConfigPath should end with the mcpConfigName - expect(profileConfig.mcpConfigPath).toMatch( - new RegExp( - `${profileConfig.mcpConfigName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$` - ) - ); + // 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, '\\$&')}/` + ) + ); + } } }); }); diff --git a/tests/unit/profiles/zed-integration.test.js b/tests/unit/profiles/zed-integration.test.js new file mode 100644 index 00000000..67cdbcbf --- /dev/null +++ b/tests/unit/profiles/zed-integration.test.js @@ -0,0 +1,99 @@ +import { jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Mock external modules +jest.mock('child_process', () => ({ + execSync: jest.fn() +})); + +// Mock console methods +jest.mock('console', () => ({ + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + clear: jest.fn() +})); + +describe('Zed 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('settings.json')) { + return JSON.stringify({ context_servers: {} }, 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 createProjectStructure behavior for Zed files + function mockCreateZedStructure() { + // Create main .zed directory + fs.mkdirSync(path.join(tempDir, '.zed'), { recursive: true }); + + // Create MCP config file (settings.json) + fs.writeFileSync( + path.join(tempDir, '.zed', 'settings.json'), + JSON.stringify({ context_servers: {} }, null, 2) + ); + + // Create AGENTS.md in project root + fs.writeFileSync( + path.join(tempDir, 'AGENTS.md'), + '# Task Master Instructions\n\nThis is the Task Master agents file.' + ); + } + + test('creates all required .zed directories', () => { + // Act + mockCreateZedStructure(); + + // Assert + expect(fs.mkdirSync).toHaveBeenCalledWith(path.join(tempDir, '.zed'), { + recursive: true + }); + }); + + test('creates Zed settings.json with context_servers format', () => { + // Act + mockCreateZedStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, '.zed', 'settings.json'), + JSON.stringify({ context_servers: {} }, null, 2) + ); + }); + + test('creates AGENTS.md in project root', () => { + // Act + mockCreateZedStructure(); + + // Assert + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tempDir, 'AGENTS.md'), + '# Task Master Instructions\n\nThis is the Task Master agents file.' + ); + }); +});