From b0e09c76ed73b00434ac95606679f570f1015a3d Mon Sep 17 00:00:00 2001 From: Joe Danziger Date: Wed, 16 Jul 2025 12:13:21 -0400 Subject: [PATCH] feat: Add Zed editor rule profile with agent rules and MCP config (#974) * zed profile * add changeset * update changeset --- .changeset/metal-papers-stay.md | 7 + src/constants/profiles.js | 6 +- src/profiles/index.js | 1 + src/profiles/zed.js | 178 +++++++++++++++ src/utils/profiles.js | 4 +- .../profiles/mcp-config-validation.test.js | 6 + .../profiles/rule-transformer-zed.test.js | 212 ++++++++++++++++++ tests/unit/profiles/rule-transformer.test.js | 8 +- tests/unit/profiles/zed-integration.test.js | 99 ++++++++ 9 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 .changeset/metal-papers-stay.md create mode 100644 src/profiles/zed.js create mode 100644 tests/unit/profiles/rule-transformer-zed.test.js create mode 100644 tests/unit/profiles/zed-integration.test.js 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/src/constants/profiles.js b/src/constants/profiles.js index bd861474..edc59fe1 100644 --- a/src/constants/profiles.js +++ b/src/constants/profiles.js @@ -1,5 +1,5 @@ /** - * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode'} RulesProfile + * @typedef {'amp' | 'claude' | 'cline' | 'codex' | 'cursor' | 'gemini' | 'roo' | 'trae' | 'windsurf' | 'vscode' | 'zed'} RulesProfile */ /** @@ -20,6 +20,7 @@ * - 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 @@ -36,7 +37,8 @@ export const RULE_PROFILES = [ 'roo', 'trae', 'vscode', - 'windsurf' + 'windsurf', + 'zed' ]; /** diff --git a/src/profiles/index.js b/src/profiles/index.js index f603d1c9..e353533c 100644 --- a/src/profiles/index.js +++ b/src/profiles/index.js @@ -9,3 +9,4 @@ 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/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 def22ff1..cdf9cbd0 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, amp) - don't include standard coding rules + // Integration guide profiles (claude, codex, gemini, 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 (profileName === 'gemini' || profileName === 'zed') { description = 'Integration guide and MCP config'; } else if (profileName === 'amp') { description = 'Integration guide and MCP config'; diff --git a/tests/unit/profiles/mcp-config-validation.test.js b/tests/unit/profiles/mcp-config-validation.test.js index b1545fb2..d9cc2554 100644 --- a/tests/unit/profiles/mcp-config-validation.test.js +++ b/tests/unit/profiles/mcp-config-validation.test.js @@ -46,6 +46,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' } }; 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 07a669f3..33b417c2 100644 --- a/tests/unit/profiles/rule-transformer.test.js +++ b/tests/unit/profiles/rule-transformer.test.js @@ -22,7 +22,8 @@ describe('Rule Transformer - General', () => { 'roo', 'trae', 'vscode', - 'windsurf' + 'windsurf', + 'zed' ]; expectedProfiles.forEach((profile) => { expect(RULE_PROFILES).toContain(profile); @@ -229,6 +230,11 @@ describe('Rule Transformer - General', () => { mcpConfig: true, mcpConfigName: 'mcp.json', expectedPath: '.windsurf/mcp.json' + }, + zed: { + mcpConfig: true, + mcpConfigName: 'settings.json', + expectedPath: '.zed/settings.json' } }; 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.' + ); + }); +});