mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 07:12:18 +00:00
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:
1 parent
fd8fec20d7
commit
77d026869e
37 files changed
+6813
-200
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user