mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-29 22:02:04 +00:00
feat(profiles): auto-enable deferred MCP loading for Claude Code (#1525)
This commit is contained in:
9
.changeset/yummy-nights-repeat.md
Normal file
9
.changeset/yummy-nights-repeat.md
Normal file
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
506
packages/tm-profiles/src/shell-utils.spec.ts
Normal file
506
packages/tm-profiles/src/shell-utils.spec.ts
Normal file
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
222
packages/tm-profiles/src/shell-utils.ts
Normal file
222
packages/tm-profiles/src/shell-utils.ts
Normal file
@@ -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)'
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user