fix(tsconfig): remove duplicate compiler options

- Remove duplicate allowSyntheticDefaultImports (lines 16 & 42)
- Remove duplicate skipLibCheck (lines 18 & 46)
- Add TypeScript source files for npm package migration
- Verify build pipeline works (42/42 tests pass)
This commit is contained in:
kaitranntt committed 2025-11-26 19:24:40 -05:00
1 parent fd8fec20d7
commit 77d026869e
37 files changed
+6813 -200

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
import * as fs from 'fs';
import { execSync } from 'child_process';
import { expandPath } from './helpers';
import { ClaudeCliInfo } from '../types';
/**
* Detect Claude CLI executable
*/
export function detectClaudeCli(): string | null {
// Priority 1: CCS_CLAUDE_PATH environment variable (if user wants custom path)
if (process.env.CCS_CLAUDE_PATH) {
const ccsPath = expandPath(process.env.CCS_CLAUDE_PATH);
// Basic validation: file exists
if (fs.existsSync(ccsPath)) {
return ccsPath;
}
// Invalid CCS_CLAUDE_PATH - show warning and fall back to PATH
console.warn('[!] Warning: CCS_CLAUDE_PATH is set but file not found:', ccsPath);
console.warn(' Falling back to system PATH lookup...');
}
// Priority 2: Resolve 'claude' from PATH using which/where.exe
// This fixes Windows npm installation where spawn() can't resolve bare command names
// SECURITY: Commands are hardcoded literals with no user input - safe from injection
const isWindows = process.platform === 'win32';
try {
const cmd = isWindows ? 'where.exe claude' : 'which claude';
const result = execSync(cmd, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000 // 5 second timeout to prevent hangs
}).trim();
// where.exe may return multiple lines (all matches in PATH order)
const matches = result.split('\n').map(p => p.trim()).filter(p => p);
if (isWindows) {
// On Windows, prefer executables with extensions (.exe, .cmd, .bat)
// where.exe often returns file without extension first, then the actual .cmd wrapper
const withExtension = matches.find(p => /\.(exe|cmd|bat|ps1)$/i.test(p));
const claudePath = withExtension || matches[0];
if (claudePath && fs.existsSync(claudePath)) {
return claudePath;
}
} else {
// On Unix, first match is fine
const claudePath = matches[0];
if (claudePath && fs.existsSync(claudePath)) {
return claudePath;
}
}
} catch (err) {
// Command failed - claude not in PATH
// Fall through to return null
}
// Priority 3: Claude not found
return null;
}
/**
* Get detailed Claude CLI information
*/
export function getClaudeCliInfo(): ClaudeCliInfo | null {
const claudePath = detectClaudeCli();
if (!claudePath) {
return null;
}
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudePath);
return {
path: claudePath,
isWindows,
needsShell
};
}
/**
* Show Claude not found error
*/
export function showClaudeNotFoundError(): never {
console.error('ERROR: Claude CLI not found in PATH');
console.error('Install from: https://docs.claude.com/en/docs/claude-code/installation');
process.exit(1);
}
+113
View File
@@ -0,0 +1,113 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { Config, isConfig, Settings, isSettings } from '../types';
import { expandPath } from './helpers';
import { error } from './helpers';
// TODO: Replace with proper imports after converting these files
// const { ErrorManager } = require('./error-manager');
// const RecoveryManager = require('./recovery-manager');
/**
* Get config file path
*/
export function getConfigPath(): string {
return process.env.CCS_CONFIG || path.join(os.homedir(), '.ccs', 'config.json');
}
/**
* Load and validate config.json
*/
export function loadConfig(): Config {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
// TODO: Add recovery manager logic
// const recovery = new RecoveryManager();
// recovery.ensureConfigJson();
error(`Config not found: ${configPath}`);
}
const raw = fs.readFileSync(configPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!isConfig(parsed)) {
error(`Invalid config format: ${configPath}`);
}
return parsed;
}
/**
* Load and validate settings.json
*/
export function loadSettings(settingsPath: string): Settings {
if (!fs.existsSync(settingsPath)) {
error(`Settings not found: ${settingsPath}`);
}
const raw = fs.readFileSync(settingsPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!isSettings(parsed)) {
error(`Invalid settings format: ${settingsPath}`);
}
return parsed;
}
/**
* Read and parse config (legacy compatibility)
*/
export function readConfig(): Config {
return loadConfig();
}
/**
* Get settings path for profile
*/
export function getSettingsPath(profile: string): string {
const config = readConfig();
// Get settings path
const settingsPath = config.profiles[profile];
if (!settingsPath) {
const availableProfiles = Object.keys(config.profiles);
const profileList = availableProfiles.map(p => ` - ${p}`);
error(`Profile '${profile}' not found. Available profiles:\n${profileList.join('\n')}`);
}
// Expand path
const expandedPath = expandPath(settingsPath);
// Validate settings file exists
if (!fs.existsSync(expandedPath)) {
// Auto-create if it's ~/.claude/settings.json
if (expandedPath.includes('.claude') && expandedPath.endsWith('settings.json')) {
// TODO: Add recovery manager logic
// const recovery = new RecoveryManager();
// recovery.ensureClaudeSettings();
console.log('[i] Auto-created missing settings file');
} else {
error(`Settings file not found: ${expandedPath}`);
}
}
// Validate settings file is valid JSON
try {
const settingsContent = fs.readFileSync(expandedPath, 'utf8');
JSON.parse(settingsContent);
} catch (e) {
if (e instanceof Error) {
error(`Invalid JSON in settings file: ${expandedPath} - ${e.message}`);
} else {
error(`Invalid JSON in settings file: ${expandedPath}`);
}
}
return expandedPath;
}
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
interface ValidationResult {
valid: boolean;
error?: string;
suggestion?: string;
settingsPath?: string;
apiKey?: string;
}
/**
* Validates delegation profiles for CCS delegation system
* Ensures profiles exist and have valid API keys configured
*/
export class DelegationValidator {
/**
* Validate a delegation profile
* @param profileName - Name of profile to validate (e.g., 'glm', 'kimi')
* @returns Validation result { valid: boolean, error?: string, settingsPath?: string }
*/
static validate(profileName: string): ValidationResult {
const homeDir = os.homedir();
const settingsPath = path.join(homeDir, '.ccs', `${profileName}.settings.json`);
// Check if profile directory exists
if (!fs.existsSync(settingsPath)) {
return {
valid: false,
error: `Profile not found: ${profileName}`,
suggestion: `Profile settings missing at: ${settingsPath}\n\n` +
`To set up ${profileName} profile:\n` +
` 1. Copy base settings: cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json\n` +
` 2. Edit settings: Edit ~/.ccs/${profileName}.settings.json\n` +
` 3. Set your API key in ANTHROPIC_AUTH_TOKEN field`
};
}
// Read and parse settings.json
let settings: any;
try {
const settingsContent = fs.readFileSync(settingsPath, 'utf8');
settings = JSON.parse(settingsContent);
} catch (error) {
return {
valid: false,
error: `Failed to parse settings.json for ${profileName}`,
suggestion: `Settings file is corrupted or invalid JSON.\n\n` +
`Location: ${settingsPath}\n` +
`Parse error: ${(error as Error).message}\n\n` +
`Fix: Restore from base config:\n` +
` cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json`
};
}
// Validate API key exists and is not default
const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN;
if (!apiKey) {
return {
valid: false,
error: `API key not configured for ${profileName}`,
suggestion: `Missing ANTHROPIC_AUTH_TOKEN in settings.\n\n` +
`Edit: ${settingsPath}\n` +
`Set: env.ANTHROPIC_AUTH_TOKEN to your API key`
};
}
// Check for default placeholder values
const defaultPlaceholders = [
'YOUR_GLM_API_KEY_HERE',
'YOUR_KIMI_API_KEY_HERE',
'YOUR_API_KEY_HERE',
'your-api-key-here',
'PLACEHOLDER'
];
if (defaultPlaceholders.some(placeholder => apiKey.includes(placeholder))) {
return {
valid: false,
error: `Default API key placeholder detected for ${profileName}`,
suggestion: `API key is still set to default placeholder.\n\n` +
`To configure your profile:\n` +
` 1. Edit: ${settingsPath}\n` +
` 2. Replace ANTHROPIC_AUTH_TOKEN with your actual API key\n\n` +
`Get API key:\n` +
` GLM: https://z.ai/manage-apikey/apikey-list\n` +
` Kimi: https://platform.moonshot.cn/console/api-keys`
};
}
// Validation passed
return {
valid: true,
settingsPath,
apiKey: apiKey.substring(0, 8) + '...' // Show first 8 chars for verification
};
}
/**
* Format validation error for display
* @param result - Validation result from validate()
* @returns Formatted error message
*/
static formatError(result: ValidationResult): string {
if (result.valid) {
return '';
}
let message = `\n[X] ${result.error}\n\n`;
if (result.suggestion) {
message += `${result.suggestion}\n`;
}
return message;
}
/**
* Check if profile is delegation-ready (shorthand)
* @param profileName - Profile to check
* @returns True if ready for delegation
*/
static isReady(profileName: string): boolean {
const result = this.validate(profileName);
return result.valid;
}
/**
* Get all delegation-ready profiles
* @returns List of profile names ready for delegation
*/
static getReadyProfiles(): string[] {
const homeDir = os.homedir();
const ccsDir = path.join(homeDir, '.ccs');
if (!fs.existsSync(ccsDir)) {
return [];
}
const profiles: string[] = [];
const entries = fs.readdirSync(ccsDir, { withFileTypes: true });
// Look for *.settings.json files
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.settings.json')) {
const profileName = entry.name.replace('.settings.json', '');
if (this.isReady(profileName)) {
profiles.push(profileName);
}
}
}
return profiles;
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* CCS Error Codes
* Documentation: ../../docs/errors/README.md
*/
export const ERROR_CODES = {
// Configuration Errors (E100-E199)
CONFIG_MISSING: 'E101',
CONFIG_INVALID_JSON: 'E102',
CONFIG_INVALID_PROFILE: 'E103',
// Profile Management Errors (E200-E299)
PROFILE_NOT_FOUND: 'E104',
PROFILE_ALREADY_EXISTS: 'E105',
PROFILE_CANNOT_DELETE_DEFAULT: 'E106',
PROFILE_INVALID_NAME: 'E107',
// Claude CLI Detection Errors (E300-E399)
CLAUDE_NOT_FOUND: 'E301',
CLAUDE_VERSION_INCOMPATIBLE: 'E302',
CLAUDE_EXECUTION_FAILED: 'E303',
// Network/API Errors (E400-E499)
GLMT_PROXY_TIMEOUT: 'E401',
API_KEY_MISSING: 'E402',
API_AUTH_FAILED: 'E403',
API_RATE_LIMIT: 'E404',
// File System Errors (E500-E599)
FS_CANNOT_CREATE_DIR: 'E501',
FS_CANNOT_WRITE_FILE: 'E502',
FS_CANNOT_READ_FILE: 'E503',
FS_INSTANCE_NOT_FOUND: 'E504',
// Internal Errors (E900-E999)
INTERNAL_ERROR: 'E900',
INVALID_STATE: 'E901'
} as const;
export type ErrorCode = typeof ERROR_CODES[keyof typeof ERROR_CODES];
/**
* Error code documentation URL generator
*/
export function getErrorDocUrl(errorCode: ErrorCode): string {
return `https://github.com/kaitranntt/ccs/blob/main/docs/errors/README.md#${errorCode.toLowerCase()}`;
}
/**
* Get error category from code
*/
export function getErrorCategory(errorCode: ErrorCode): string {
const code = parseInt(errorCode.substring(1));
if (code >= 100 && code < 200) return 'Configuration';
if (code >= 200 && code < 300) return 'Profile Management';
if (code >= 300 && code < 400) return 'Claude CLI Detection';
if (code >= 400 && code < 500) return 'Network/API';
if (code >= 500 && code < 600) return 'File System';
if (code >= 900 && code < 1000) return 'Internal';
return 'Unknown';
}
+159
View File
@@ -0,0 +1,159 @@
import { colored } from './helpers';
import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes';
/**
* Error types with structured messages (Legacy - kept for compatibility)
*/
export const ErrorTypes = {
NO_CLAUDE_CLI: 'NO_CLAUDE_CLI',
MISSING_SETTINGS: 'MISSING_SETTINGS',
INVALID_CONFIG: 'INVALID_CONFIG',
UNKNOWN_PROFILE: 'UNKNOWN_PROFILE',
PERMISSION_DENIED: 'PERMISSION_DENIED',
GENERIC: 'GENERIC'
} as const;
export type ErrorType = typeof ErrorTypes[keyof typeof ErrorTypes];
/**
* Enhanced error manager with context-aware messages
*/
export class ErrorManager {
/**
* Show error code and documentation URL
*/
static showErrorCode(errorCode: ErrorCode): void {
console.error(colored(`Error: ${errorCode}`, 'yellow'));
console.error(colored(getErrorDocUrl(errorCode), 'yellow'));
console.error('');
}
/**
* Show Claude CLI not found error
*/
static showClaudeNotFound(): void {
console.error('');
console.error(colored('[X] Claude CLI not found', 'red'));
console.error('');
console.error('CCS requires Claude CLI to be installed and available in PATH.');
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' 1. Install Claude CLI:');
console.error(' https://docs.claude.com/en/docs/claude-code/installation');
console.error('');
console.error(' 2. Verify installation:');
console.error(' command -v claude (Unix)');
console.error(' Get-Command claude (Windows)');
console.error('');
console.error(' 3. Custom path (if installed elsewhere):');
console.error(' export CCS_CLAUDE_PATH="/path/to/claude"');
console.error('');
this.showErrorCode(ERROR_CODES.CLAUDE_NOT_FOUND);
}
/**
* Show settings file not found error
*/
static showSettingsNotFound(settingsPath: string): void {
const isClaudeSettings = settingsPath.includes('.claude') && settingsPath.endsWith('settings.json');
console.error('');
console.error(colored('[X] Settings file not found', 'red'));
console.error('');
console.error(`File: ${settingsPath}`);
console.error('');
if (isClaudeSettings) {
console.error('This file is auto-created when you login to Claude CLI.');
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(` echo '{}' > ${settingsPath}`);
console.error(' claude /login');
console.error('');
console.error('Why: Newer Claude CLI versions require explicit login.');
} else {
console.error(colored('Solutions:', 'yellow'));
console.error(' npm install -g @kaitranntt/ccs --force');
console.error('');
console.error('This will recreate missing profile settings.');
}
console.error('');
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_PROFILE);
}
/**
* Show invalid configuration error
*/
static showInvalidConfig(configPath: string, errorDetail: string): void {
console.error('');
console.error(colored('[X] Configuration invalid', 'red'));
console.error('');
console.error(`File: ${configPath}`);
console.error(`Issue: ${errorDetail}`);
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Backup corrupted file');
console.error(` mv ${configPath} ${configPath}.backup`);
console.error('');
console.error(' # Reinstall CCS');
console.error(' npm install -g @kaitranntt/ccs --force');
console.error('');
console.error('Your profile settings will be preserved.');
console.error('');
this.showErrorCode(ERROR_CODES.CONFIG_INVALID_JSON);
}
/**
* Show profile not found error
*/
static showProfileNotFound(
profileName: string,
availableProfiles: string[],
suggestions: string[] = []
): void {
console.error('');
console.error(colored(`[X] Profile '${profileName}' not found`, 'red'));
console.error('');
if (suggestions && suggestions.length > 0) {
console.error(colored('Did you mean:', 'yellow'));
suggestions.forEach(s => console.error(` ${s}`));
console.error('');
}
console.error(colored('Available profiles:', 'cyan'));
availableProfiles.forEach(line => console.error(` ${line}`));
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Use existing profile');
console.error(' ccs <profile> "your prompt"');
console.error('');
console.error(' # Create new account profile');
console.error(' ccs auth create <name>');
console.error('');
this.showErrorCode(ERROR_CODES.PROFILE_NOT_FOUND);
}
/**
* Show permission denied error
*/
static showPermissionDenied(path: string): void {
console.error('');
console.error(colored('[X] Permission denied', 'red'));
console.error('');
console.error(`Cannot write to: ${path}`);
console.error('');
console.error(colored('Solutions:', 'yellow'));
console.error(' # Fix ownership');
console.error(' sudo chown -R $USER ~/.ccs ~/.claude');
console.error('');
console.error(' # Fix permissions');
console.error(' chmod 755 ~/.ccs ~/.claude');
console.error('');
console.error(' # Retry installation');
console.error(' npm install -g @kaitranntt/ccs --force');
console.error('');
this.showErrorCode(ERROR_CODES.FS_CANNOT_WRITE_FILE);
}
}
+140
View File
@@ -0,0 +1,140 @@
import * as path from 'path';
import * as os from 'os';
import { ColorName, TerminalInfo } from '../types';
/**
* TTY-aware color detection (matches lib/ccs bash logic)
*/
function getColors(): Record<ColorName, string> {
const forcedColors = process.env.FORCE_COLOR;
const noColor = process.env.NO_COLOR;
const isTTY = process.stdout.isTTY === true; // Must be explicitly true
const useColors = !!forcedColors || (isTTY && !noColor);
if (useColors) {
return {
red: '\x1b[0;31m',
yellow: '\x1b[1;33m',
cyan: '\x1b[0;36m',
green: '\x1b[0;32m',
blue: '\x1b[0;34m',
bold: '\x1b[1m',
reset: '\x1b[0m'
};
}
return { red: '', yellow: '', cyan: '', green: '', blue: '', bold: '', reset: '' };
}
// Colors object (dynamic)
export const colors = getColors();
/**
* Helper: Apply color to text (returns plain text if colors disabled)
*/
export function colored(text: string, colorName: ColorName = 'reset'): string {
const currentColors = getColors();
const color = currentColors[colorName] || '';
return color ? `${color}${text}${currentColors.reset}` : text;
}
/**
* Simple error formatting
*/
export function error(message: string): never {
console.error(`ERROR: ${message}`);
console.error('Try: npm install -g @kaitranntt/ccs --force');
process.exit(1);
}
/**
* Path expansion (~ and env vars)
*/
export function expandPath(pathStr: string): string {
// Handle tilde expansion
if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) {
pathStr = path.join(os.homedir(), pathStr.slice(2));
}
// Expand environment variables (Windows and Unix)
pathStr = pathStr.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || '');
pathStr = pathStr.replace(/\$([A-Z_][A-Z0-9_]*)/gi, (_, name) => process.env[name] || '');
// Windows %VAR% style
if (process.platform === 'win32') {
pathStr = pathStr.replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
}
return path.normalize(pathStr);
}
/**
* Detect terminal capabilities
*/
export function getTerminalInfo(): TerminalInfo {
return {
isTTY: process.stdout.isTTY ?? false,
supportsColor: (process.stdout.isTTY ?? false) && !process.env.NO_COLOR,
noColorEnv: !!process.env.NO_COLOR,
};
}
/**
* Calculate Levenshtein distance between two strings
*/
export function levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
// Initialize first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the rest of the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
/**
* Find similar strings using fuzzy matching
*/
export function findSimilarStrings(
target: string,
candidates: string[],
maxDistance: number = 2
): string[] {
const targetLower = target.toLowerCase();
const matches = candidates
.map(candidate => ({
name: candidate,
distance: levenshteinDistance(targetLower, candidate.toLowerCase())
}))
.filter(item => item.distance <= maxDistance && item.distance > 0)
.sort((a, b) => a.distance - b.distance)
.slice(0, 3) // Show at most 3 suggestions
.map(item => item.name);
return matches;
}
+130
View File
@@ -0,0 +1,130 @@
import * as readline from 'readline';
/**
* Interactive Prompt Utilities (NO external dependencies)
*
* Features:
* - TTY detection (auto-confirm in non-TTY)
* - --yes flag support for automation
* - --no-input flag support for CI
* - Safe defaults (N for destructive actions)
* - Input validation with retry
*/
interface ConfirmOptions {
default?: boolean;
}
interface InputOptions {
default?: string;
validate?: (value: string) => string | null;
}
export class InteractivePrompt {
/**
* Ask for confirmation
*/
static async confirm(message: string, options: ConfirmOptions = {}): Promise<boolean> {
const { default: defaultValue = false } = options;
// Check for --yes flag (automation) - always returns true
if (process.env.CCS_YES === '1' || process.argv.includes('--yes') || process.argv.includes('-y')) {
return true;
}
// Check for --no-input flag (CI)
if (process.env.CCS_NO_INPUT === '1' || process.argv.includes('--no-input')) {
throw new Error('Interactive input required but --no-input specified');
}
// Non-TTY: use default
if (!process.stdin.isTTY) {
return defaultValue;
}
// Interactive prompt
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
terminal: true
});
const promptText = defaultValue
? `${message} [Y/n]: `
: `${message} [y/N]: `;
return new Promise((resolve) => {
rl.question(promptText, (answer: string) => {
rl.close();
const normalized = answer.trim().toLowerCase();
// Empty answer: use default
if (normalized === '') {
resolve(defaultValue);
return;
}
// Valid answers
if (normalized === 'y' || normalized === 'yes') {
resolve(true);
return;
}
if (normalized === 'n' || normalized === 'no') {
resolve(false);
return;
}
// Invalid input: retry
console.error('[!] Please answer y or n');
resolve(InteractivePrompt.confirm(message, options));
});
});
}
/**
* Get text input from user
*/
static async input(message: string, options: InputOptions = {}): Promise<string> {
const { default: defaultValue = '', validate = null } = options;
// Non-TTY: use default or error
if (!process.stdin.isTTY) {
if (defaultValue) {
return defaultValue;
}
throw new Error('Interactive input required but stdin is not a TTY');
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
terminal: true
});
const promptText = defaultValue
? `${message} [${defaultValue}]: `
: `${message}: `;
return new Promise((resolve) => {
rl.question(promptText, (answer: string) => {
rl.close();
const value = answer.trim() || defaultValue;
// Validate input if validator provided
if (validate) {
const error = validate(value);
if (error) {
console.error(`[!] ${error}`);
resolve(InteractivePrompt.input(message, options));
return;
}
}
resolve(value);
});
});
}
}
+263
View File
@@ -0,0 +1,263 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// import { execSync } from 'child_process'; // Currently unused
type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell' | null;
interface InstallResult {
success: boolean;
alreadyInstalled?: boolean;
message?: string;
reload?: string;
}
/**
* Shell Completion Installer
* Auto-configures shell completion for bash, zsh, fish, PowerShell
*/
export class ShellCompletionInstaller {
private homeDir: string;
private ccsDir: string;
private completionDir: string;
private scriptsDir: string;
constructor() {
this.homeDir = os.homedir();
this.ccsDir = path.join(this.homeDir, '.ccs');
this.completionDir = path.join(this.ccsDir, 'completions');
this.scriptsDir = path.join(__dirname, '../../scripts/completion');
}
/**
* Detect current shell
*/
detectShell(): ShellType {
const shell = process.env.SHELL || '';
if (shell.includes('bash')) return 'bash';
if (shell.includes('zsh')) return 'zsh';
if (shell.includes('fish')) return 'fish';
if (process.platform === 'win32') return 'powershell';
return null;
}
/**
* Ensure completion files are in ~/.ccs/completions/
*/
ensureCompletionFiles(): void {
if (!fs.existsSync(this.completionDir)) {
fs.mkdirSync(this.completionDir, { recursive: true });
}
// Copy completion scripts
const files = ['ccs.bash', 'ccs.zsh', 'ccs.fish', 'ccs.ps1'];
files.forEach(file => {
const src = path.join(this.scriptsDir, file);
const dest = path.join(this.completionDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
}
});
}
/**
* Safely create directory, checking for file conflicts
*/
private ensureDirectory(dirPath: string): void {
if (fs.existsSync(dirPath)) {
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
throw new Error(
`Cannot create directory: ${dirPath} exists but is a file.\n` +
`Please remove or rename this file and try again.`
);
}
// Directory exists, nothing to do
return;
}
// Check parent directories recursively
const parentDir = path.dirname(dirPath);
if (parentDir !== dirPath) {
this.ensureDirectory(parentDir);
}
// Create the directory
fs.mkdirSync(dirPath);
}
/**
* Install bash completion
*/
private installBash(): InstallResult {
const rcFile = path.join(this.homeDir, '.bashrc');
const completionPath = path.join(this.completionDir, 'ccs.bash');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
const marker = '# CCS shell completion';
const sourceCmd = `source "${completionPath}"`;
const block = `\n${marker}\n${sourceCmd}\n`;
// Check if already installed
if (fs.existsSync(rcFile)) {
const content = fs.readFileSync(rcFile, 'utf8');
if (content.includes(marker)) {
return { success: true, alreadyInstalled: true };
}
}
// Append to .bashrc
fs.appendFileSync(rcFile, block);
return {
success: true,
message: `Added to ${rcFile}`,
reload: 'source ~/.bashrc'
};
}
/**
* Install zsh completion
*/
private installZsh(): InstallResult {
const rcFile = path.join(this.homeDir, '.zshrc');
const completionPath = path.join(this.completionDir, 'ccs.zsh');
const zshCompDir = path.join(this.homeDir, '.zsh', 'completion');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
// Create zsh completion directory (with file conflict checking)
this.ensureDirectory(zshCompDir);
// Copy to zsh completion directory
const destFile = path.join(zshCompDir, '_ccs');
fs.copyFileSync(completionPath, destFile);
const marker = '# CCS shell completion';
const setupCmds = [
'fpath=(~/.zsh/completion $fpath)',
'autoload -Uz compinit && compinit'
];
const block = `\n${marker}\n${setupCmds.join('\n')}\n`;
// Check if already installed
if (fs.existsSync(rcFile)) {
const content = fs.readFileSync(rcFile, 'utf8');
if (content.includes(marker)) {
return { success: true, alreadyInstalled: true };
}
}
// Append to .zshrc
fs.appendFileSync(rcFile, block);
return {
success: true,
message: `Added to ${rcFile}`,
reload: 'source ~/.zshrc'
};
}
/**
* Install fish completion
*/
private installFish(): InstallResult {
const completionPath = path.join(this.completionDir, 'ccs.fish');
const fishCompDir = path.join(this.homeDir, '.config', 'fish', 'completions');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
// Create fish completion directory (with file conflict checking)
this.ensureDirectory(fishCompDir);
// Copy to fish completion directory (fish auto-loads from here)
const destFile = path.join(fishCompDir, 'ccs.fish');
fs.copyFileSync(completionPath, destFile);
return {
success: true,
message: `Installed to ${destFile}`,
reload: 'Fish auto-loads completions (no reload needed)'
};
}
/**
* Install PowerShell completion
*/
private installPowerShell(): InstallResult {
const profilePath = process.env.PROFILE || path.join(
this.homeDir,
'Documents',
'PowerShell',
'Microsoft.PowerShell_profile.ps1'
);
const completionPath = path.join(this.completionDir, 'ccs.ps1');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
const marker = '# CCS shell completion';
const sourceCmd = `. "${completionPath.replace(/\\/g, '\\\\')}"`;
const block = `\n${marker}\n${sourceCmd}\n`;
// Create profile directory if needed (with file conflict checking)
const profileDir = path.dirname(profilePath);
this.ensureDirectory(profileDir);
// Check if already installed
if (fs.existsSync(profilePath)) {
const content = fs.readFileSync(profilePath, 'utf8');
if (content.includes(marker)) {
return { success: true, alreadyInstalled: true };
}
}
// Append to PowerShell profile
fs.appendFileSync(profilePath, block);
return {
success: true,
message: `Added to ${profilePath}`,
reload: '. $PROFILE'
};
}
/**
* Install for detected or specified shell
*/
install(shell: ShellType = null): InstallResult {
const targetShell = shell || this.detectShell();
if (!targetShell) {
throw new Error('Could not detect shell. Please specify: --bash, --zsh, --fish, or --powershell');
}
// Ensure completion files exist
this.ensureCompletionFiles();
// Install for target shell
switch (targetShell) {
case 'bash':
return this.installBash();
case 'zsh':
return this.installZsh();
case 'fish':
return this.installFish();
case 'powershell':
return this.installPowerShell();
default:
throw new Error(`Unsupported shell: ${targetShell}`);
}
}
}