mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat(profiles): auto-enable deferred MCP loading for Claude Code (#1525)
This commit is contained in:
@@ -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)'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user