From 1c2228dbb618e522798c4484b74c1508f13d61d6 Mon Sep 17 00:00:00 2001 From: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:12:24 +0100 Subject: [PATCH] feat(profiles): auto-enable deferred MCP loading for Claude Code (#1525) --- .changeset/yummy-nights-repeat.md | 9 + .mcp.json | 5 +- packages/tm-profiles/src/index.ts | 3 + packages/tm-profiles/src/shell-utils.spec.ts | 506 ++++++++++++++++++ packages/tm-profiles/src/shell-utils.ts | 222 ++++++++ src/profiles/claude.js | 27 +- .../claude-init-functionality.test.js | 82 ++- 7 files changed, 851 insertions(+), 3 deletions(-) create mode 100644 .changeset/yummy-nights-repeat.md create mode 100644 packages/tm-profiles/src/shell-utils.spec.ts create mode 100644 packages/tm-profiles/src/shell-utils.ts diff --git a/.changeset/yummy-nights-repeat.md b/.changeset/yummy-nights-repeat.md new file mode 100644 index 00000000..a71d9f96 --- /dev/null +++ b/.changeset/yummy-nights-repeat.md @@ -0,0 +1,9 @@ +--- +"task-master-ai": minor +--- + +Add tool search tool for Claude Code MCP server and enable deferred MCP loading + +- Added new tool search tool capabilities for the Taskmaster MCP in Claude Code +- Running `task-master rules add claude` now automatically configures your shell (`~/.zshrc`, `~/.bashrc`, or PowerShell profile) with `ENABLE_EXPERIMENTAL_MCP_CLI=true` to enable deferred MCP loading +- **Context savings**: Deferred loading saves ~16% of Claude Code's 200k context window (~33k tokens for Task Master alone). Savings apply to all MCP servers, so total savings may be higher depending on your setup. diff --git a/.mcp.json b/.mcp.json index 26a129bf..a22fb510 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,10 @@ "task-master-ai": { "type": "stdio", "command": "npx", - "args": ["-y", "task-master-ai"] + "args": ["-y", "task-master-ai"], + "env": { + "TASK_MASTER_TOOLS": "all" + } } } } diff --git a/packages/tm-profiles/src/index.ts b/packages/tm-profiles/src/index.ts index 1c79fb21..90b5365e 100644 --- a/packages/tm-profiles/src/index.ts +++ b/packages/tm-profiles/src/index.ts @@ -5,3 +5,6 @@ // Re-export everything from slash-commands module export * from './slash-commands/index.js'; + +// Re-export shell utilities +export * from './shell-utils.js'; diff --git a/packages/tm-profiles/src/shell-utils.spec.ts b/packages/tm-profiles/src/shell-utils.spec.ts new file mode 100644 index 00000000..ba970a2d --- /dev/null +++ b/packages/tm-profiles/src/shell-utils.spec.ts @@ -0,0 +1,506 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + addShellExport, + enableDeferredMcpLoading, + getShellConfigPath +} from './shell-utils.js'; + +// Mock fs and os modules +vi.mock('fs'); +vi.mock('os'); + +describe('shell-utils', () => { + const mockHomeDir = '/home/testuser'; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getShellConfigPath', () => { + it('should return .zshrc for zsh shell', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + + // Act + const result = getShellConfigPath(); + + // Assert + expect(result).toBe(path.join(mockHomeDir, '.zshrc')); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should return .bashrc for bash shell on Linux', () => { + // Arrange + const originalShell = process.env.SHELL; + const originalPlatform = process.platform; + process.env.SHELL = '/bin/bash'; + Object.defineProperty(process, 'platform', { value: 'linux' }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + // Act + const result = getShellConfigPath(); + + // Assert + expect(result).toBe(path.join(mockHomeDir, '.bashrc')); + + // Cleanup + process.env.SHELL = originalShell; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should return .bash_profile for bash shell on macOS if it exists', () => { + // Arrange + const originalShell = process.env.SHELL; + const originalPlatform = process.platform; + process.env.SHELL = '/bin/bash'; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Act + const result = getShellConfigPath(); + + // Assert + expect(result).toBe(path.join(mockHomeDir, '.bash_profile')); + + // Cleanup + process.env.SHELL = originalShell; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should return .config/fish/config.fish for fish shell', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/usr/bin/fish'; + + // Act + const result = getShellConfigPath(); + + // Assert + expect(result).toBe( + path.join(mockHomeDir, '.config', 'fish', 'config.fish') + ); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should fallback to .zshrc if it exists when shell is unknown', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/unknown'; + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes('.zshrc') + ); + + // Act + const result = getShellConfigPath(); + + // Assert + expect(result).toBe(path.join(mockHomeDir, '.zshrc')); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should return null if no shell config is found', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/unknown'; + vi.mocked(fs.existsSync).mockReturnValue(false); + + // Act + const result = getShellConfigPath(); + + // Assert + expect(result).toBeNull(); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should return PowerShell profile on Windows with PSModulePath', () => { + // Arrange + const originalShell = process.env.SHELL; + const originalPSModulePath = process.env.PSModulePath; + const originalPlatform = process.platform; + process.env.SHELL = ''; + process.env.PSModulePath = 'C:\\some\\path'; + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.mocked(fs.existsSync).mockReturnValue(false); + + // Act + const result = getShellConfigPath(); + + // Assert + expect(result).toBe( + path.join( + mockHomeDir, + 'Documents', + 'PowerShell', + 'Microsoft.PowerShell_profile.ps1' + ) + ); + + // Cleanup + process.env.SHELL = originalShell; + process.env.PSModulePath = originalPSModulePath; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should return Git Bash .bashrc on Windows without PSModulePath', () => { + // Arrange + const originalShell = process.env.SHELL; + const originalPSModulePath = process.env.PSModulePath; + const originalPlatform = process.platform; + process.env.SHELL = ''; + delete process.env.PSModulePath; + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes('.bashrc') + ); + + // Act + const result = getShellConfigPath(); + + // Assert + expect(result).toBe(path.join(mockHomeDir, '.bashrc')); + + // Cleanup + process.env.SHELL = originalShell; + process.env.PSModulePath = originalPSModulePath; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + }); + + describe('addShellExport', () => { + it('should add export to shell config file', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + const shellConfigPath = path.join(mockHomeDir, '.zshrc'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('# existing content\n'); + vi.mocked(fs.appendFileSync).mockImplementation(() => {}); + + // Act + const result = addShellExport('MY_VAR', 'my_value', 'My comment'); + + // Assert + expect(result.success).toBe(true); + expect(result.shellConfigFile).toBe(shellConfigPath); + expect(result.alreadyExists).toBeUndefined(); + expect(fs.appendFileSync).toHaveBeenCalledWith( + shellConfigPath, + '\n# My comment\nexport MY_VAR=my_value\n' + ); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should skip if export already exists', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + '# existing\nexport MY_VAR=old_value\n' + ); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(true); + expect(result.alreadyExists).toBe(true); + expect(fs.appendFileSync).not.toHaveBeenCalled(); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should NOT skip when variable name only appears in a comment', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + const shellConfigPath = path.join(mockHomeDir, '.zshrc'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + '# MY_VAR is mentioned here but not exported\n# Another comment about MY_VAR\n' + ); + vi.mocked(fs.appendFileSync).mockImplementation(() => {}); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(true); + expect(result.alreadyExists).toBeUndefined(); + expect(fs.appendFileSync).toHaveBeenCalledWith( + shellConfigPath, + '\nexport MY_VAR=my_value\n' + ); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should NOT skip when variable name is a substring of another variable', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + const shellConfigPath = path.join(mockHomeDir, '.zshrc'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'export MY_VAR_EXTENDED=some_value\nexport OTHER_MY_VAR=another\n' + ); + vi.mocked(fs.appendFileSync).mockImplementation(() => {}); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(true); + expect(result.alreadyExists).toBeUndefined(); + expect(fs.appendFileSync).toHaveBeenCalledWith( + shellConfigPath, + '\nexport MY_VAR=my_value\n' + ); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should detect existing export with leading whitespace', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(' export MY_VAR=old_value\n'); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(true); + expect(result.alreadyExists).toBe(true); + expect(fs.appendFileSync).not.toHaveBeenCalled(); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should return failure if shell config not found', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + vi.mocked(fs.existsSync).mockReturnValue(false); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(false); + expect(result.message).toContain('not found'); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should return failure if shell type cannot be determined', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/unknown'; + vi.mocked(fs.existsSync).mockReturnValue(false); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(false); + expect(result.message).toContain('Could not determine shell type'); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should use PowerShell syntax for .ps1 files', () => { + // Arrange + const originalShell = process.env.SHELL; + const originalPSModulePath = process.env.PSModulePath; + const originalPlatform = process.platform; + process.env.SHELL = ''; + process.env.PSModulePath = 'C:\\some\\path'; + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('# existing content\n'); + vi.mocked(fs.appendFileSync).mockImplementation(() => {}); + + // Act + const result = addShellExport('MY_VAR', 'my_value', 'My comment'); + + // Assert + expect(result.success).toBe(true); + expect(fs.appendFileSync).toHaveBeenCalledWith( + expect.stringContaining('.ps1'), + '\n# My comment\n$env:MY_VAR = "my_value"\n' + ); + + // Cleanup + process.env.SHELL = originalShell; + process.env.PSModulePath = originalPSModulePath; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should NOT skip PowerShell when variable name only appears in a comment', () => { + // Arrange + const originalShell = process.env.SHELL; + const originalPSModulePath = process.env.PSModulePath; + const originalPlatform = process.platform; + process.env.SHELL = ''; + process.env.PSModulePath = 'C:\\some\\path'; + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + '# MY_VAR is mentioned here but not set\n# $env:MY_VAR in a comment\n' + ); + vi.mocked(fs.appendFileSync).mockImplementation(() => {}); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(true); + expect(result.alreadyExists).toBeUndefined(); + expect(fs.appendFileSync).toHaveBeenCalledWith( + expect.stringContaining('.ps1'), + '\n$env:MY_VAR = "my_value"\n' + ); + + // Cleanup + process.env.SHELL = originalShell; + process.env.PSModulePath = originalPSModulePath; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should detect existing PowerShell export', () => { + // Arrange + const originalShell = process.env.SHELL; + const originalPSModulePath = process.env.PSModulePath; + const originalPlatform = process.platform; + process.env.SHELL = ''; + process.env.PSModulePath = 'C:\\some\\path'; + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('$env:MY_VAR = "old_value"\n'); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(true); + expect(result.alreadyExists).toBe(true); + expect(fs.appendFileSync).not.toHaveBeenCalled(); + + // Cleanup + process.env.SHELL = originalShell; + process.env.PSModulePath = originalPSModulePath; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should create PowerShell profile and directory if they do not exist', () => { + // Arrange + const originalShell = process.env.SHELL; + const originalPSModulePath = process.env.PSModulePath; + const originalPlatform = process.platform; + process.env.SHELL = ''; + process.env.PSModulePath = 'C:\\some\\path'; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + // Nothing exists initially + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => {}); + vi.mocked(fs.readFileSync).mockReturnValue(''); + vi.mocked(fs.appendFileSync).mockImplementation(() => {}); + + // Act + const result = addShellExport('MY_VAR', 'my_value'); + + // Assert + expect(result.success).toBe(true); + // Should create the profile directory + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { + recursive: true + }); + // Should create the empty profile file + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('.ps1'), + '' + ); + + // Cleanup + process.env.SHELL = originalShell; + process.env.PSModulePath = originalPSModulePath; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + }); + + describe('enableDeferredMcpLoading', () => { + it('should add ENABLE_EXPERIMENTAL_MCP_CLI export', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + const shellConfigPath = path.join(mockHomeDir, '.zshrc'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('# existing content\n'); + vi.mocked(fs.appendFileSync).mockImplementation(() => {}); + + // Act + const result = enableDeferredMcpLoading(); + + // Assert + expect(result.success).toBe(true); + expect(result.shellConfigFile).toBe(shellConfigPath); + expect(fs.appendFileSync).toHaveBeenCalledWith( + shellConfigPath, + '\n# Claude Code deferred MCP loading (added by Taskmaster)\nexport ENABLE_EXPERIMENTAL_MCP_CLI=true\n' + ); + + // Cleanup + process.env.SHELL = originalShell; + }); + + it('should skip if already configured', () => { + // Arrange + const originalShell = process.env.SHELL; + process.env.SHELL = '/bin/zsh'; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + 'export ENABLE_EXPERIMENTAL_MCP_CLI=true\n' + ); + + // Act + const result = enableDeferredMcpLoading(); + + // Assert + expect(result.success).toBe(true); + expect(result.alreadyExists).toBe(true); + expect(fs.appendFileSync).not.toHaveBeenCalled(); + + // Cleanup + process.env.SHELL = originalShell; + }); + }); +}); diff --git a/packages/tm-profiles/src/shell-utils.ts b/packages/tm-profiles/src/shell-utils.ts new file mode 100644 index 00000000..cd5a753e --- /dev/null +++ b/packages/tm-profiles/src/shell-utils.ts @@ -0,0 +1,222 @@ +/** + * @fileoverview Shell configuration utilities for tm-profiles + * Provides functions to modify shell configuration files (bashrc, zshrc, etc.) + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +export interface ShellConfigResult { + success: boolean; + shellConfigFile?: string; + message: string; + alreadyExists?: boolean; +} + +/** + * Detects the user's shell configuration file path + * Supports: zsh, bash, fish, PowerShell (Windows) + * @returns The path to the shell config file, or null if not detected + */ +export function getShellConfigPath(): string | null { + const homeDir = os.homedir(); + const shell = process.env.SHELL || ''; + + // Unix-like shells (Linux, macOS, WSL, Git Bash) + if (shell.includes('zsh')) { + return path.join(homeDir, '.zshrc'); + } + + if (shell.includes('bash')) { + // macOS uses .bash_profile for login shells - prefer it even if it doesn't exist yet + const bashProfile = path.join(homeDir, '.bash_profile'); + if (process.platform === 'darwin') { + return bashProfile; + } + return path.join(homeDir, '.bashrc'); + } + + if (shell.includes('fish')) { + return path.join(homeDir, '.config', 'fish', 'config.fish'); + } + + // Windows PowerShell - check $PROFILE env var or use default location + if (process.platform === 'win32') { + // PowerShell sets PSModulePath when running + if (process.env.PSModulePath) { + // Use $PROFILE if set, otherwise use default PowerShell profile path + const psProfile = + process.env.PROFILE || + path.join( + homeDir, + 'Documents', + 'WindowsPowerShell', + 'Microsoft.PowerShell_profile.ps1' + ); + // Also check PowerShell Core location + const pwshProfile = path.join( + homeDir, + 'Documents', + 'PowerShell', + 'Microsoft.PowerShell_profile.ps1' + ); + + if (fs.existsSync(pwshProfile)) return pwshProfile; + if (fs.existsSync(psProfile)) return psProfile; + + // Return PowerShell Core path as default (more modern) + return pwshProfile; + } + + // Git Bash on Windows - check for .bashrc + const bashrc = path.join(homeDir, '.bashrc'); + if (fs.existsSync(bashrc)) return bashrc; + } + + // Fallback - check what exists (covers WSL and other edge cases) + const zshrc = path.join(homeDir, '.zshrc'); + if (fs.existsSync(zshrc)) return zshrc; + + const bashrc = path.join(homeDir, '.bashrc'); + if (fs.existsSync(bashrc)) return bashrc; + + return null; +} + +/** + * Checks if a shell config file is a PowerShell profile + */ +function isPowerShellProfile(filePath: string): boolean { + return filePath.endsWith('.ps1'); +} + +/** + * Adds an export statement to the user's shell configuration file + * Handles both Unix-style shells (bash, zsh, fish) and PowerShell + * @param envVar - The environment variable name + * @param value - The value to set + * @param comment - Optional comment to add above the export + * @returns Result object with success status and details + */ +export function addShellExport( + envVar: string, + value: string, + comment?: string +): ShellConfigResult { + // Validate envVar contains only safe characters (prevents ReDoS attacks) + if (!/^[A-Z_][A-Z0-9_]*$/i.test(envVar)) { + return { + success: false, + message: `Invalid environment variable name: ${envVar}. Must start with a letter or underscore and contain only alphanumeric characters and underscores.` + }; + } + + // Validate value to prevent shell injection + if ( + typeof value !== 'string' || + value.includes('\n') || + value.includes('\r') + ) { + return { + success: false, + message: `Invalid value: must be a single-line string without newlines` + }; + } + + const shellConfigFile = getShellConfigPath(); + + if (!shellConfigFile) { + return { + success: false, + message: 'Could not determine shell type (zsh, bash, fish, or PowerShell)' + }; + } + + try { + // Create the profile directory if it doesn't exist (handles fish's nested ~/.config/fish/ and PowerShell) + const profileDir = path.dirname(shellConfigFile); + if (!fs.existsSync(profileDir)) { + fs.mkdirSync(profileDir, { recursive: true }); + } + + // Check if file exists, create empty if it doesn't (common for PowerShell and macOS .bash_profile) + if (!fs.existsSync(shellConfigFile)) { + const isBashProfileOnMac = + process.platform === 'darwin' && + shellConfigFile.endsWith('.bash_profile'); + if (isPowerShellProfile(shellConfigFile) || isBashProfileOnMac) { + // Create empty profile file + fs.writeFileSync(shellConfigFile, ''); + } else { + return { + success: false, + shellConfigFile, + message: `Shell config file ${shellConfigFile} not found` + }; + } + } + + const content = fs.readFileSync(shellConfigFile, 'utf8'); + + // Check if the export already exists using precise regex patterns + // This avoids false positives from comments or partial matches + const alreadyExists = isPowerShellProfile(shellConfigFile) + ? new RegExp(`^\\s*\\$env:${envVar}\\s*=`, 'm').test(content) + : new RegExp(`^\\s*export\\s+${envVar}\\s*=`, 'm').test(content); + + if (alreadyExists) { + return { + success: true, + shellConfigFile, + message: `${envVar} already configured`, + alreadyExists: true + }; + } + + // Build the export block based on shell type + let exportLine: string; + let commentPrefix: string; + + if (isPowerShellProfile(shellConfigFile)) { + // PowerShell syntax - escape quotes and backticks + const escapedValue = value.replace(/["`$]/g, '`$&'); + exportLine = `$env:${envVar} = "${escapedValue}"`; + commentPrefix = '#'; + } else { + // Unix shell syntax (bash, zsh, fish) - use single quotes and escape embedded single quotes + const escapedValue = value.replace(/'/g, "'\\''"); + exportLine = `export ${envVar}='${escapedValue}'`; + commentPrefix = '#'; + } + + const commentLine = comment ? `${commentPrefix} ${comment}\n` : ''; + const block = `\n${commentLine}${exportLine}\n`; + + fs.appendFileSync(shellConfigFile, block); + + return { + success: true, + shellConfigFile, + message: `Added ${envVar} to ${shellConfigFile}` + }; + } catch (error) { + return { + success: false, + shellConfigFile, + message: `Failed to modify shell config: ${(error as Error).message}` + }; + } +} + +/** + * Enables Claude Code deferred MCP loading by adding the required env var + * @returns Result object with success status and details + */ +export function enableDeferredMcpLoading(): ShellConfigResult { + return addShellExport( + 'ENABLE_EXPERIMENTAL_MCP_CLI', + 'true', + 'Claude Code deferred MCP loading (added by Taskmaster)' + ); +} diff --git a/src/profiles/claude.js b/src/profiles/claude.js index 205e18b5..ebc314e8 100644 --- a/src/profiles/claude.js +++ b/src/profiles/claude.js @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; import { isSilentMode, log } from '../../scripts/modules/utils.js'; import { createProfile } from './base-profile.js'; +import { enableDeferredMcpLoading } from '@tm/profiles'; // Helper function to recursively copy directory (adopted from Roo profile) function copyRecursiveSync(src, dest) { @@ -103,6 +104,25 @@ function onAddRulesProfile(targetDir, assetsDir) { ); } } + + // Enable deferred MCP loading for reduced context usage + const deferredResult = enableDeferredMcpLoading(); + if (deferredResult.success) { + if (deferredResult.alreadyExists) { + log('debug', '[Claude] Deferred MCP loading already configured'); + } else { + log( + 'info', + `[Claude] Enabled deferred MCP loading in ${deferredResult.shellConfigFile}` + ); + log('info', '[Claude] Restart your terminal for changes to take effect'); + } + } else { + log( + 'debug', + `[Claude] Could not configure deferred loading: ${deferredResult.message}` + ); + } } function onRemoveRulesProfile(targetDir) { @@ -273,4 +293,9 @@ export const claudeProfile = createProfile({ }); // Export lifecycle functions separately to avoid naming conflicts -export { onAddRulesProfile, onRemoveRulesProfile, onPostConvertRulesProfile }; +export { + onAddRulesProfile, + onRemoveRulesProfile, + onPostConvertRulesProfile, + transformToClaudeFormat +}; diff --git a/tests/integration/profiles/claude-init-functionality.test.js b/tests/integration/profiles/claude-init-functionality.test.js index 7ae49dc3..11db112d 100644 --- a/tests/integration/profiles/claude-init-functionality.test.js +++ b/tests/integration/profiles/claude-init-functionality.test.js @@ -1,6 +1,9 @@ import fs from 'fs'; import path from 'path'; -import { claudeProfile } from '../../../src/profiles/claude.js'; +import { + claudeProfile, + transformToClaudeFormat +} from '../../../src/profiles/claude.js'; describe('Claude Profile Initialization Functionality', () => { let claudeProfileContent; @@ -60,3 +63,80 @@ describe('Claude Profile Initialization Functionality', () => { expect(claudeProfileContent).toContain("log('error'"); }); }); + +describe('transformToClaudeFormat', () => { + test('should add type: stdio to MCP server configs', () => { + const input = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['-y', 'task-master-ai'], + env: { ANTHROPIC_API_KEY: 'test-key' } + } + } + }; + + const result = transformToClaudeFormat(input); + + expect(result.mcpServers['task-master-ai']).toEqual({ + type: 'stdio', + command: 'npx', + args: ['-y', 'task-master-ai'], + env: { ANTHROPIC_API_KEY: 'test-key' } + }); + }); + + test('should place type as first key in output', () => { + const input = { + mcpServers: { + 'my-server': { + command: 'node', + args: ['server.js'] + } + } + }; + + const result = transformToClaudeFormat(input); + const keys = Object.keys(result.mcpServers['my-server']); + + // type should be first key + expect(keys[0]).toBe('type'); + expect(keys[1]).toBe('command'); + expect(keys[2]).toBe('args'); + }); + + test('should handle multiple MCP servers', () => { + const input = { + mcpServers: { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2', args: ['arg1'] } + } + }; + + const result = transformToClaudeFormat(input); + + expect(result.mcpServers.server1.type).toBe('stdio'); + expect(result.mcpServers.server2.type).toBe('stdio'); + }); + + test('should preserve additional non-standard properties', () => { + const input = { + mcpServers: { + 'my-server': { + command: 'node', + customProperty: 'custom-value' + } + } + }; + + const result = transformToClaudeFormat(input); + + expect(result.mcpServers['my-server'].customProperty).toBe('custom-value'); + }); + + test('should return empty object when input has no mcpServers', () => { + const input = {}; + const result = transformToClaudeFormat(input); + expect(result).toEqual({}); + }); +});