feat(profiles): auto-enable deferred MCP loading for Claude Code (#1525)

This commit is contained in:
Ralph Khreish
2025-12-16 23:12:24 +01:00
committed by GitHub
parent e7b9f82f21
commit 1c2228dbb6
7 changed files with 851 additions and 3 deletions

View File

@@ -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';

View 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;
});
});
});

View 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)'
);
}