From 6c5e0f97f8403c4da85c1abba31cb8b1789511a7 Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Wed, 16 Jul 2025 08:44:37 -0400 Subject: [PATCH] feat: Add Amp rule profile with AGENT.md and MCP config (#973) * Amp profile + tests * generatlize to Agent instead of Claude Code to support any agent * add changeset * unnecessary tab formatting * fix exports * fix formatting --- .changeset/public-crabs-ask.md | 5 + assets/AGENTS.md | 2 +- src/constants/profiles.js | 4 +- src/profiles/amp.js | 277 ++++++++++++++ src/profiles/index.js | 1 + src/utils/profiles.js | 8 +- .../profiles/amp-init-functionality.test.js | 346 ++++++++++++++++++ tests/unit/profiles/amp-integration.test.js | 299 +++++++++++++++ .../profiles/mcp-config-validation.test.js | 21 +- tests/unit/profiles/rule-transformer.test.js | 5 + 10 files changed, 958 insertions(+), 10 deletions(-) create mode 100644 .changeset/public-crabs-ask.md create mode 100644 src/profiles/amp.js create mode 100644 tests/integration/profiles/amp-init-functionality.test.js create mode 100644 tests/unit/profiles/amp-integration.test.js 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/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/src/constants/profiles.js b/src/constants/profiles.js index 861ed406..bd861474 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' | 'roo' | 'trae' | 'windsurf' | 'vscode'} RulesProfile */ /** @@ -10,6 +10,7 @@ * * @type {RulesProfile[]} * @description Defines possible rule profile sets: + * - amp: Amp Code integration * - claude: Claude Code integration * - cline: Cline IDE rules * - codex: Codex integration @@ -26,6 +27,7 @@ * 3. Export it as {profile}Profile in src/profiles/index.js */ export const RULE_PROFILES = [ + 'amp', 'claude', 'cline', 'codex', 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/index.js b/src/profiles/index.js index 01b1b9fc..f603d1c9 100644 --- a/src/profiles/index.js +++ b/src/profiles/index.js @@ -1,4 +1,5 @@ // 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'; diff --git a/src/utils/profiles.js b/src/utils/profiles.js index 32a2b7cf..def22ff1 100644 --- a/src/utils/profiles.js +++ b/src/utils/profiles.js @@ -113,13 +113,15 @@ 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, 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') { description = 'Integration guide and MCP config'; + } else if (profileName === 'amp') { + description = 'Integration guide and MCP config'; } else { description = 'Integration guide'; } @@ -199,7 +201,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 +227,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/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/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index 91f4c0cb..b1545fb2 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -143,6 +143,8 @@ describe('MCP Configuration Validation', () => { const profileDirs = new Set(); // Profiles that use root directory (can share the same directory) const rootProfiles = ['claude', 'codex', 'gemini']; + // Profiles that intentionally share the same directory + const sharedDirectoryProfiles = ['amp', 'vscode']; // Both use .vscode RULE_PROFILES.forEach((profileName) => { const profile = getRulesProfile(profileName); @@ -152,10 +154,18 @@ 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'); } }); }); @@ -307,6 +317,7 @@ describe('MCP Configuration Validation', () => { describe('Profile structure validation', () => { const mcpProfiles = [ + 'amp', 'cursor', 'gemini', 'roo', @@ -315,7 +326,7 @@ describe('MCP Configuration Validation', () => { 'trae', 'vscode' ]; - const profilesWithLifecycle = ['claude']; + const profilesWithLifecycle = ['amp', 'claude']; const profilesWithoutLifecycle = ['codex']; test.each(mcpProfiles)( diff --git a/tests/unit/profiles/rule-transformer.test.js b/tests/unit/profiles/rule-transformer.test.js index 6ab1083a..07a669f3 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -180,6 +180,11 @@ describe('Rule Transformer - General', () => { it('should have correct MCP configuration for each profile', () => { const expectedConfigs = { + amp: { + mcpConfig: true, + mcpConfigName: 'settings.json', + expectedPath: '.vscode/settings.json' + }, claude: { mcpConfig: true, mcpConfigName: '.mcp.json',