Files
Claude-Code-Workflow/ccw/src/commands/install.ts

1257 lines
37 KiB
TypeScript

import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync, unlinkSync, rmdirSync, appendFileSync, renameSync } from 'fs';
import { join, dirname, basename } from 'path';
import { homedir, platform } from 'os';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { showHeader, createSpinner, info, warning, error, summaryBox, divider } from '../utils/ui.js';
import { createManifest, addFileEntry, addDirectoryEntry, saveManifest, findManifest, getAllManifests, type Manifest, type ManifestWithMetadata } from '../core/manifest.js';
import { validatePath } from '../utils/path-resolver.js';
import type { Ora } from 'ora';
// Git Bash fix markers
const GITBASH_FIX_START = '# >>> ccw gitbash fix';
const GITBASH_FIX_END = '# <<< ccw gitbash fix';
// Supported shell configuration files
const SHELL_CONFIG_FILES = [
{ name: '.bashrc', description: 'Bash configuration (recommended for Git Bash)' },
{ name: '.bash_profile', description: 'Bash login shell configuration' },
{ name: '.profile', description: 'Generic shell profile' },
{ name: '.zshrc', description: 'Zsh configuration' }
];
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Source directories to install (includes .codex with prompts folder)
const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen', '.ccw'];
// Subdirectories that should always be installed to global (~/.claude/)
const GLOBAL_SUBDIRS = ['workflows', 'scripts', 'templates'];
// Files that should be excluded from cleanup (user-specific settings)
const EXCLUDED_FILES = ['settings.json', 'settings.local.json'];
interface InstallOptions {
mode?: string;
path?: string;
force?: boolean;
}
interface CopyResult {
files: number;
directories: number;
}
// Disabled item tracking for install process
interface DisabledItem {
name: string;
path: string;
type: 'skill' | 'command';
}
interface DisabledItems {
skills: DisabledItem[];
commands: DisabledItem[];
}
/**
* Scan for disabled skills and commands before installation
* Skills: look for SKILL.md.disabled files
* Commands: look for *.md.disabled files
*/
function scanDisabledItems(installPath: string, globalPath?: string): DisabledItems {
const result: DisabledItems = { skills: [], commands: [] };
const pathsToScan = [installPath];
if (globalPath && globalPath !== installPath) {
pathsToScan.push(globalPath);
}
for (const basePath of pathsToScan) {
// Scan skills
const skillsDir = join(basePath, '.claude', 'skills');
if (existsSync(skillsDir)) {
try {
const entries = readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const disabledPath = join(skillsDir, entry.name, 'SKILL.md.disabled');
if (existsSync(disabledPath)) {
result.skills.push({
name: entry.name,
path: disabledPath,
type: 'skill'
});
}
}
}
} catch {
// Ignore errors
}
}
// Scan commands recursively
const commandsDir = join(basePath, '.claude', 'commands');
if (existsSync(commandsDir)) {
scanDisabledCommandsRecursive(commandsDir, commandsDir, result.commands);
}
}
return result;
}
/**
* Recursively scan for disabled command files
*/
function scanDisabledCommandsRecursive(baseDir: string, currentDir: string, results: DisabledItem[]): void {
try {
const entries = readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(currentDir, entry.name);
if (entry.isDirectory()) {
scanDisabledCommandsRecursive(baseDir, fullPath, results);
} else if (entry.isFile() && entry.name.endsWith('.md.disabled')) {
const relativePath = fullPath.substring(baseDir.length + 1);
const commandName = relativePath.replace(/\.disabled$/, '');
results.push({
name: commandName,
path: fullPath,
type: 'command'
});
}
}
} catch {
// Ignore errors
}
}
/**
* Restore disabled state after installation
* For each previously disabled item, if the enabled version exists, rename it back to disabled
*/
function restoreDisabledState(
disabledItems: DisabledItems,
installPath: string,
globalPath?: string
): { skillsRestored: number; commandsRestored: number } {
let skillsRestored = 0;
let commandsRestored = 0;
// Restore skills
for (const skill of disabledItems.skills) {
// Determine which path this skill belongs to
const skillDir = dirname(skill.path);
const enabledPath = join(skillDir, 'SKILL.md');
const disabledPath = join(skillDir, 'SKILL.md.disabled');
// If enabled version was installed, rename it to disabled
if (existsSync(enabledPath)) {
try {
// Remove old disabled file if it still exists (shouldn't, but be safe)
if (existsSync(disabledPath)) {
unlinkSync(disabledPath);
}
renameSync(enabledPath, disabledPath);
skillsRestored++;
} catch {
// Ignore errors
}
}
}
// Restore commands
for (const command of disabledItems.commands) {
const enabledPath = command.path.replace(/\.disabled$/, '');
const disabledPath = command.path;
// If enabled version was installed, rename it to disabled
if (existsSync(enabledPath)) {
try {
// Remove old disabled file if it still exists
if (existsSync(disabledPath)) {
unlinkSync(disabledPath);
}
renameSync(enabledPath, disabledPath);
commandsRestored++;
} catch {
// Ignore errors
}
}
}
return { skillsRestored, commandsRestored };
}
// Get package root directory (ccw/src/commands -> ccw)
function getPackageRoot(): string {
return join(__dirname, '..', '..');
}
// Get source installation directory (parent of ccw)
function getSourceDir(): string {
return join(getPackageRoot(), '..');
}
/**
* Install command handler
* @param {Object} options - Command options
*/
export async function installCommand(options: InstallOptions): Promise<void> {
const version = getVersion();
// Show beautiful header
showHeader(version);
// Check for existing installations
const existingManifests = getAllManifests();
if (existingManifests.length > 0 && !options.force) {
info('Existing installations detected:');
console.log('');
existingManifests.forEach((m, i) => {
console.log(chalk.gray(` ${i + 1}. ${m.installation_mode} - ${m.installation_path}`));
console.log(chalk.gray(` Installed: ${new Date(m.installation_date).toLocaleDateString()}`));
});
console.log('');
const { proceed } = await inquirer.prompt([{
type: 'confirm',
name: 'proceed',
message: 'Continue with new installation?',
default: true
}]);
if (!proceed) {
info('Installation cancelled');
return;
}
}
// Local installation from package source
const sourceDir = getSourceDir();
// Interactive mode selection
const mode = options.mode || await selectMode();
let installPath: string;
if (mode === 'Global') {
installPath = homedir();
info(`Global installation to: ${installPath}`);
} else {
const inputPath = options.path || await selectPath();
// Validate the installation path
const pathValidation = validatePath(inputPath, { mustExist: true });
if (!pathValidation.valid || !pathValidation.path) {
error(`Invalid installation path: ${pathValidation.error}`);
process.exit(1);
}
installPath = pathValidation.path;
info(`Path installation to: ${installPath}`);
}
// Validate source directories exist
const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir)));
if (availableDirs.length === 0) {
error('No source directories found to install.');
error(`Expected directories in: ${sourceDir}`);
process.exit(1);
}
console.log('');
info(`Found ${availableDirs.length} directories to install: ${availableDirs.join(', ')}`);
// Show what will be installed including .codex subdirectories
if (availableDirs.includes('.codex')) {
const codexPath = join(sourceDir, '.codex');
// Show prompts info
const promptsPath = join(codexPath, 'prompts');
if (existsSync(promptsPath)) {
const promptFiles = readdirSync(promptsPath, { recursive: true }).filter(f =>
statSync(join(promptsPath, f.toString())).isFile()
);
info(` └─ .codex/prompts: ${promptFiles.length} files`);
}
// Show agents info
const agentsPath = join(codexPath, 'agents');
if (existsSync(agentsPath)) {
const agentFiles = readdirSync(agentsPath).filter(f => f.endsWith('.md'));
info(` └─ .codex/agents: ${agentFiles.length} agent definitions`);
}
// Show skills info
const skillsPath = join(codexPath, 'skills');
if (existsSync(skillsPath)) {
const skillDirs = readdirSync(skillsPath).filter(f =>
statSync(join(skillsPath, f)).isDirectory()
);
info(` └─ .codex/skills: ${skillDirs.length} skills`);
}
}
divider();
// Check for existing installation manifest
const existingManifest = findManifest(installPath, mode);
let cleanStats = { removed: 0, skipped: 0 };
if (existingManifest) {
// Has manifest - clean based on manifest records
warning('Existing installation found at this location');
info(` Files in manifest: ${existingManifest.files?.length || 0}`);
info(` Installed: ${new Date(existingManifest.installation_date).toLocaleDateString()}`);
if (!options.force) {
const { backup } = await inquirer.prompt([{
type: 'confirm',
name: 'backup',
message: 'Create backup before reinstalling?',
default: true
}]);
if (backup) {
await createBackup(existingManifest);
}
}
// Clean based on manifest records
console.log('');
const cleanSpinner = createSpinner('Cleaning previous installation...').start();
try {
cleanSpinner.text = 'Removing manifest files...';
cleanStats = await cleanManifestFiles(existingManifest, cleanSpinner);
if (cleanStats.removed > 0 || cleanStats.skipped > 0) {
cleanSpinner.succeed(`Cleanup: ${cleanStats.removed} files removed, ${cleanStats.skipped} settings preserved`);
} else {
cleanSpinner.succeed('Cleanup: no files to remove');
}
} catch (err) {
const errMsg = err as Error;
cleanSpinner.warn(`Cleanup warning: ${errMsg.message}`);
}
} else {
// No manifest - first install or manual install, just overwrite
const existingDirs = SOURCE_DIRS.filter(dir => existsSync(join(installPath, dir)));
if (existingDirs.length > 0) {
info('No installation manifest found, files will be overwritten');
info(` Existing directories: ${existingDirs.join(', ')}`);
}
}
// Scan for disabled items before installation
const globalPath = mode === 'Path' ? homedir() : undefined;
const disabledItems = scanDisabledItems(installPath, globalPath);
const totalDisabled = disabledItems.skills.length + disabledItems.commands.length;
if (totalDisabled > 0) {
info(`Found ${totalDisabled} disabled items (${disabledItems.skills.length} skills, ${disabledItems.commands.length} commands)`);
}
// Create manifest
const manifest = createManifest(mode, installPath);
// Perform installation
console.log('');
const spinner = createSpinner('Installing files...').start();
let totalFiles = 0;
let totalDirs = 0;
let restoreStats = { skillsRestored: 0, commandsRestored: 0 };
try {
// For Path mode, install workflows to global first
if (mode === 'Path') {
const globalPath = homedir();
for (const subdir of GLOBAL_SUBDIRS) {
const srcWorkflows = join(sourceDir, '.claude', subdir);
if (existsSync(srcWorkflows)) {
const destWorkflows = join(globalPath, '.claude', subdir);
spinner.text = `Installing ${subdir} to global...`;
const { files, directories } = await copyDirectory(srcWorkflows, destWorkflows, manifest);
totalFiles += files;
totalDirs += directories;
}
}
}
for (const dir of availableDirs) {
const srcPath = join(sourceDir, dir);
// .ccw always installs to global ~/.ccw/ regardless of mode
const destBase = (mode === 'Path' && dir === '.ccw') ? homedir() : installPath;
const destPath = join(destBase, dir);
spinner.text = `Installing ${dir}...`;
// For Path mode on .claude, exclude global subdirs (they're already installed to global)
const excludeDirs = (mode === 'Path' && dir === '.claude') ? GLOBAL_SUBDIRS : [];
const { files, directories } = await copyDirectory(srcPath, destPath, manifest, excludeDirs);
totalFiles += files;
totalDirs += directories;
}
// Create version.json
const versionPath = join(installPath, '.claude', 'version.json');
if (existsSync(dirname(versionPath))) {
const versionData = {
version: version,
installedAt: new Date().toISOString(),
mode: mode,
installer: 'ccw'
};
writeFileSync(versionPath, JSON.stringify(versionData, null, 2), 'utf8');
addFileEntry(manifest, versionPath);
totalFiles++;
}
spinner.succeed('Installation complete!');
// Restore disabled state for previously disabled items
if (totalDisabled > 0) {
restoreStats = restoreDisabledState(disabledItems, installPath, globalPath);
const totalRestored = restoreStats.skillsRestored + restoreStats.commandsRestored;
if (totalRestored > 0) {
info(`Restored ${totalRestored} disabled items (${restoreStats.skillsRestored} skills, ${restoreStats.commandsRestored} commands)`);
}
}
} catch (err) {
spinner.fail('Installation failed');
const errMsg = err as Error;
error(errMsg.message);
process.exit(1);
}
// Save manifest
const manifestPath = saveManifest(manifest);
// Show summary
console.log('');
const summaryLines = [
chalk.green.bold('✓ Installation Successful'),
'',
chalk.white(`Mode: ${chalk.cyan(mode)}`),
chalk.white(`Path: ${chalk.cyan(installPath)}`),
chalk.white(`Version: ${chalk.cyan(version)}`),
'',
chalk.gray(`Files installed: ${totalFiles}`),
chalk.gray(`Directories created: ${totalDirs}`)
];
// Add cleanup stats if any files were processed
if (cleanStats.removed > 0 || cleanStats.skipped > 0) {
summaryLines.push(chalk.gray(`Old files removed: ${cleanStats.removed}`));
if (cleanStats.skipped > 0) {
summaryLines.push(chalk.gray(`Settings preserved: ${cleanStats.skipped}`));
}
}
// Add restore stats if any disabled items were restored
if (restoreStats.skillsRestored > 0 || restoreStats.commandsRestored > 0) {
const totalRestored = restoreStats.skillsRestored + restoreStats.commandsRestored;
summaryLines.push(chalk.gray(`Disabled state restored: ${totalRestored} items`));
}
summaryLines.push('');
summaryLines.push(chalk.gray(`Manifest: ${basename(manifestPath)}`));
// Add codex components info if installed
if (availableDirs.includes('.codex')) {
const codexPath = join(installPath, '.codex');
summaryLines.push('');
summaryLines.push(chalk.cyan('Codex Components:'));
// Prompts
const promptsPath = join(codexPath, 'prompts');
if (existsSync(promptsPath)) {
summaryLines.push(chalk.gray(` ✓ prompts: ${promptsPath}`));
}
// Agents
const agentsPath = join(codexPath, 'agents');
if (existsSync(agentsPath)) {
const agentCount = readdirSync(agentsPath).filter(f => f.endsWith('.md')).length;
summaryLines.push(chalk.gray(` ✓ agents: ${agentCount} definitions`));
}
// Skills
const skillsPath = join(codexPath, 'skills');
if (existsSync(skillsPath)) {
const skillCount = readdirSync(skillsPath).filter(f =>
statSync(join(skillsPath, f)).isDirectory()
).length;
summaryLines.push(chalk.gray(` ✓ skills: ${skillCount} skills`));
}
}
summaryBox({
title: ' Installation Summary ',
lines: summaryLines,
borderColor: 'green'
});
// Install Git Bash fix on Windows
if (platform() === 'win32' && !options.force) {
console.log('');
const { installFix } = await inquirer.prompt([{
type: 'confirm',
name: 'installFix',
message: 'Install Git Bash multi-line prompt fix? (recommended for Git Bash users)',
default: false
}]);
if (installFix) {
// Let user select shell config file
const selectedConfig = await selectShellConfig();
const fixResult = await installGitBashFix(selectedConfig);
if (fixResult.installed) {
info(`Git Bash fix: ${fixResult.message}`);
info(` Run: source ${selectedConfig} (to apply immediately)`);
} else {
warning(`Git Bash fix skipped: ${fixResult.message}`);
}
}
}
// Show next steps
console.log('');
info('Next steps:');
console.log(chalk.gray(' 1. Restart Claude Code or your IDE'));
console.log(chalk.gray(' 2. Run: ccw view - to open the workflow dashboard'));
console.log(chalk.gray(' 3. Run: ccw uninstall - to remove this installation'));
console.log('');
}
/**
* Interactive mode selection
* @returns {Promise<string>} - Selected mode
*/
async function selectMode(): Promise<string> {
const { mode } = await inquirer.prompt([{
type: 'list',
name: 'mode',
message: 'Select installation mode:',
choices: [
{
name: `${chalk.cyan('Global')} - Install to home directory (recommended)`,
value: 'Global'
},
{
name: `${chalk.yellow('Path')} - Install to specific project path`,
value: 'Path'
}
]
}]);
return mode;
}
/**
* Interactive path selection
* @returns {Promise<string>} - Selected path
*/
async function selectPath(): Promise<string> {
const { path } = await inquirer.prompt([{
type: 'input',
name: 'path',
message: 'Enter installation path:',
default: process.cwd(),
validate: (input: string) => {
if (!input) return 'Path is required';
if (!existsSync(input)) {
return `Path does not exist: ${input}`;
}
return true;
}
}]);
return path;
}
/**
* Interactive shell configuration file selection
* @returns {Promise<string>} - Selected config file path
*/
async function selectShellConfig(): Promise<string> {
const home = homedir();
// Build choices with status indicators
const choices = SHELL_CONFIG_FILES.map(config => {
const fullPath = join(home, config.name);
const exists = existsSync(fullPath);
const status = exists ? chalk.green('exists') : chalk.gray('will be created');
const recommended = config.name === '.bashrc' ? chalk.yellow(' (recommended)') : '';
return {
name: `${chalk.cyan(config.name)}${recommended} - ${config.description} [${status}]`,
value: fullPath
};
});
// Add custom path option
choices.push({
name: chalk.yellow('Custom path...'),
value: 'custom'
});
const { configPath } = await inquirer.prompt([{
type: 'list',
name: 'configPath',
message: 'Select shell configuration file to install Git Bash fix:',
choices
}]);
if (configPath === 'custom') {
const { customPath } = await inquirer.prompt([{
type: 'input',
name: 'customPath',
message: 'Enter custom shell config path:',
default: join(home, '.bashrc'),
validate: (input: string) => {
if (!input) return 'Path is required';
return true;
}
}]);
return customPath;
}
return configPath;
}
/**
* Clean files based on manifest record
* Only removes files that were installed by the previous installation
* @param manifest - Existing manifest with file records
* @param spinner - Spinner for progress display
* @returns Count of removed files and skipped files
*/
async function cleanManifestFiles(
manifest: any,
spinner: Ora
): Promise<{ removed: number; skipped: number }> {
let removed = 0;
let skipped = 0;
const files = manifest.files || [];
const directories = manifest.directories || [];
// Remove files in reverse order (process deepest paths first)
const sortedFiles = [...files].sort((a: any, b: any) => b.path.length - a.path.length);
for (const fileEntry of sortedFiles) {
const filePath = fileEntry.path;
const fileName = basename(filePath);
// Skip excluded files (user settings)
if (EXCLUDED_FILES.includes(fileName)) {
skipped++;
continue;
}
try {
if (existsSync(filePath)) {
spinner.text = `Removing: ${fileName}`;
unlinkSync(filePath);
removed++;
}
} catch {
// Ignore errors during cleanup
}
}
// Clean up empty directories (deepest first)
const sortedDirs = [...directories].sort((a: any, b: any) => b.path.length - a.path.length);
for (const dirEntry of sortedDirs) {
const dirPath = dirEntry.path;
try {
if (existsSync(dirPath)) {
const contents = readdirSync(dirPath);
if (contents.length === 0) {
rmdirSync(dirPath);
}
}
} catch {
// Ignore errors during cleanup
}
}
return { removed, skipped };
}
/**
* Create backup of files recorded in manifest
* @param manifest - Existing manifest with file records
*/
async function createBackup(manifest: any): Promise<void> {
const spinner = createSpinner('Creating backup...').start();
try {
const installPath = manifest.installation_path;
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const backupDir = join(installPath, `.claude-backup-${timestamp}`);
const files = manifest.files || [];
let backedUp = 0;
for (const fileEntry of files) {
const filePath = fileEntry.path;
if (!existsSync(filePath)) continue;
// Calculate relative path from install path
const relativePath = filePath.replace(installPath, '').replace(/^[\\/]/, '');
const backupPath = join(backupDir, relativePath);
// Create directory structure
const backupFileDir = dirname(backupPath);
if (!existsSync(backupFileDir)) {
mkdirSync(backupFileDir, { recursive: true });
}
// Copy file
try {
spinner.text = `Backing up: ${basename(filePath)}`;
copyFileSync(filePath, backupPath);
backedUp++;
} catch {
// Ignore individual file errors
}
}
if (backedUp > 0) {
spinner.succeed(`Backup created: ${backupDir} (${backedUp} files)`);
} else {
spinner.info('No files to backup');
// Remove empty backup dir
try { rmdirSync(backupDir); } catch { /* ignore */ }
}
} catch (err) {
const errMsg = err as Error;
spinner.warn(`Backup failed: ${errMsg.message}`);
}
}
/**
* Copy directory recursively
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @param {Object} manifest - Manifest to track files (optional)
* @param {string[]} excludeDirs - Directory names to exclude (optional)
* @returns {Object} - Count of files and directories
*/
async function copyDirectory(
src: string,
dest: string,
manifest: any = null,
excludeDirs: string[] = [],
excludeFiles: string[] = EXCLUDED_FILES
): Promise<CopyResult> {
let files = 0;
let directories = 0;
// Create destination directory
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
directories++;
if (manifest) addDirectoryEntry(manifest, dest);
}
const entries = readdirSync(src);
for (const entry of entries) {
// Skip excluded directories
if (excludeDirs.includes(entry)) {
continue;
}
// Skip excluded files
if (excludeFiles.includes(entry)) {
continue;
}
const srcPath = join(src, entry);
const destPath = join(dest, entry);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
const result = await copyDirectory(srcPath, destPath, manifest, [], excludeFiles);
files += result.files;
directories += result.directories;
} else {
copyFileSync(srcPath, destPath);
files++;
if (manifest) addFileEntry(manifest, destPath);
}
}
return { files, directories };
}
/**
* Check if running in Git Bash on Windows
*/
function isGitBashOnWindows(): boolean {
if (platform() !== 'win32') return false;
// Check for MSYSTEM env var (set by Git Bash)
const msystem = process.env.MSYSTEM;
if (msystem && ['MINGW64', 'MINGW32', 'MSYS'].includes(msystem)) {
return true;
}
// Check for typical Git Bash shell path
const shell = process.env.SHELL || '';
if (shell.includes('bash') || shell.includes('sh')) {
return true;
}
return false;
}
/**
* Get the ccw.js path for Git Bash fix
*/
function getCcwJsPath(): string | null {
try {
const npmPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
const ccwJsPath = join(npmPrefix, 'node_modules', 'claude-code-workflow', 'ccw', 'bin', 'ccw.js');
if (existsSync(ccwJsPath)) {
return ccwJsPath;
}
return null;
} catch {
return null;
}
}
/**
* Generate Git Bash fix content for .bashrc
*/
function generateGitBashFix(ccwJsPath: string): string {
// Use Windows path format for node (works in Git Bash)
const windowsPath = ccwJsPath.replace(/\//g, '/');
return `
${GITBASH_FIX_START}
# Fix for multi-line prompt arguments in Git Bash + Windows
# npm's shell wrapper cannot handle multi-line quoted strings correctly
ccw() {
node "${windowsPath}" "$@"
}
${GITBASH_FIX_END}
`;
}
/**
* Install Git Bash fix to user's shell config
* @param targetConfigPath - Optional target config file path. If not provided, auto-detects.
* @returns true if fix was installed, false otherwise
*/
export async function installGitBashFix(targetConfigPath?: string): Promise<{ installed: boolean; message: string }> {
// Only applicable on Windows
if (platform() !== 'win32') {
return { installed: false, message: 'Not Windows platform' };
}
const ccwJsPath = getCcwJsPath();
if (!ccwJsPath) {
return { installed: false, message: 'ccw not found in npm global modules' };
}
let targetConfig: string;
if (targetConfigPath) {
// Use provided path
targetConfig = targetConfigPath;
} else {
// Auto-detect: find existing shell config file
const home = homedir();
const configFiles = [
join(home, '.bashrc'),
join(home, '.bash_profile'),
join(home, '.profile')
];
let foundConfig: string | null = null;
for (const configFile of configFiles) {
if (existsSync(configFile)) {
foundConfig = configFile;
break;
}
}
// If no config exists, default to .bashrc
targetConfig = foundConfig || join(home, '.bashrc');
}
// Check if fix already exists in target file
if (existsSync(targetConfig)) {
const content = readFileSync(targetConfig, 'utf8');
if (content.includes(GITBASH_FIX_START)) {
// Update existing fix
const fixContent = generateGitBashFix(ccwJsPath);
const regex = new RegExp(`${GITBASH_FIX_START}[\\s\\S]*?${GITBASH_FIX_END}`, 'g');
const newContent = content.replace(regex, fixContent.trim());
writeFileSync(targetConfig, newContent, 'utf8');
return { installed: true, message: `Updated in ${basename(targetConfig)}` };
}
}
// Append fix to config file (creates file if not exists)
const fixContent = generateGitBashFix(ccwJsPath);
appendFileSync(targetConfig, fixContent, 'utf8');
return { installed: true, message: `Added to ${basename(targetConfig)}` };
}
/**
* Remove Git Bash fix from user's shell config
* @returns true if fix was removed, false otherwise
*/
export function removeGitBashFix(): { removed: boolean; message: string } {
const home = homedir();
// Check all supported shell config files
const configFiles = SHELL_CONFIG_FILES.map(config => join(home, config.name));
let removed = false;
let targetFile = '';
for (const configFile of configFiles) {
if (!existsSync(configFile)) continue;
const content = readFileSync(configFile, 'utf8');
if (content.includes(GITBASH_FIX_START)) {
// Remove the fix block
const regex = new RegExp(`\\n?${GITBASH_FIX_START}[\\s\\S]*?${GITBASH_FIX_END}\\n?`, 'g');
const newContent = content.replace(regex, '\n');
writeFileSync(configFile, newContent, 'utf8');
removed = true;
targetFile = basename(configFile);
}
}
if (removed) {
return { removed: true, message: `Removed from ${targetFile}` };
}
return { removed: false, message: 'No fix found to remove' };
}
/**
* Get package version
* @returns {string} - Version string
*/
function getVersion(): string {
try {
// First try root package.json (parent of ccw)
const rootPkgPath = join(getSourceDir(), 'package.json');
if (existsSync(rootPkgPath)) {
const pkg = JSON.parse(readFileSync(rootPkgPath, 'utf8'));
if (pkg.version) return pkg.version;
}
// Fallback to ccw package.json
const pkgPath = join(getPackageRoot(), 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
return pkg.version || '1.0.0';
} catch {
return '1.0.0';
}
}
// ========================================
// Skill Hub Installation Functions
// ========================================
/**
* Options for skill-hub installation
*/
interface SkillHubInstallOptions {
skillId?: string;
cliType?: 'claude' | 'codex';
list?: boolean;
}
/**
* Skill hub index entry
*/
interface SkillHubEntry {
id: string;
name: string;
description: string;
version: string;
author: string;
category: string;
downloadUrl: string;
}
/**
* Get skill-hub directory path
*/
function getSkillHubDir(): string {
return join(homedir(), '.ccw', 'skill-hub');
}
/**
* Get local skills directory
*/
function getLocalSkillsDir(): string {
return join(getSkillHubDir(), 'local');
}
/**
* Parse skill frontmatter from SKILL.md content
*/
function parseSkillFrontmatter(content: string): {
name: string;
description: string;
version: string;
} {
const result = { name: '', description: '', version: '1.0.0' };
if (content.startsWith('---')) {
const endIndex = content.indexOf('---', 3);
if (endIndex > 0) {
const frontmatter = content.substring(3, endIndex).trim();
const lines = frontmatter.split('\n');
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim().replace(/^["']|["']$/g, '');
if (key === 'name') result.name = value;
if (key === 'description') result.description = value;
if (key === 'version') result.version = value;
}
}
}
}
return result;
}
/**
* List available skills from local skill-hub
*/
function listLocalSkillHubSkills(): Array<{ id: string; name: string; description: string; version: string; path: string }> {
const result: Array<{ id: string; name: string; description: string; version: string; path: string }> = [];
const localDir = getLocalSkillsDir();
if (!existsSync(localDir)) {
return result;
}
try {
const entries = readdirSync(localDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillDir = join(localDir, entry.name);
const skillMdPath = join(skillDir, 'SKILL.md');
if (!existsSync(skillMdPath)) continue;
try {
const content = readFileSync(skillMdPath, 'utf8');
const parsed = parseSkillFrontmatter(content);
result.push({
id: `local-${entry.name}`,
name: parsed.name || entry.name,
description: parsed.description || '',
version: parsed.version || '1.0.0',
path: skillDir,
});
} catch {
// Skip invalid skills
}
}
} catch (error) {
console.error('Failed to list local skills:', error);
}
return result;
}
/**
* Copy directory recursively with manifest tracking
* Similar to copyDirectory() but returns file count for skill-hub installation
* @param src - Source directory
* @param dest - Destination directory
* @param manifest - Manifest to track files
* @returns Count of files and directories copied
*/
function copyDirectoryWithManifest(
src: string,
dest: string,
manifest: any
): { files: number; directories: number } {
let files = 0;
let directories = 0;
// Create destination directory
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
directories++;
addDirectoryEntry(manifest, dest);
}
const entries = readdirSync(src);
for (const entry of entries) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
const result = copyDirectoryWithManifest(srcPath, destPath, manifest);
files += result.files;
directories += result.directories;
} else {
copyFileSync(srcPath, destPath);
files++;
addFileEntry(manifest, destPath);
}
}
return { files, directories };
}
/**
* Install a skill from skill-hub to CLI skills directory
* Now tracks installed files in manifest for proper uninstall cleanup
*/
async function installSkillFromHub(
skillId: string,
cliType: 'claude' | 'codex'
): Promise<{ success: boolean; message: string; filesTracked?: number }> {
// Only support local skills for now
if (!skillId.startsWith('local-')) {
return {
success: false,
message: 'Only local skills are supported in CLI. Use the web dashboard for remote skills.',
};
}
const skillName = skillId.replace('local-', '');
const localDir = getLocalSkillsDir();
const skillDir = join(localDir, skillName);
if (!existsSync(skillDir)) {
return { success: false, message: `Skill '${skillName}' not found in local skill-hub` };
}
// Get target directory
const cliDir = cliType === 'codex' ? '.codex' : '.claude';
const targetDir = join(homedir(), cliDir, 'skills', skillName);
// Check if already exists
if (existsSync(targetDir)) {
return { success: false, message: `Skill '${skillName}' already installed to ${cliType}` };
}
// Create target parent directory
const targetParent = join(homedir(), cliDir, 'skills');
if (!existsSync(targetParent)) {
mkdirSync(targetParent, { recursive: true });
}
// Get or create manifest for global installation (skill-hub always installs to home directory)
const installPath = homedir();
const existingManifest = findManifest(installPath, 'Global');
// Use existing manifest or create new one for tracking skill-hub installations
// Note: ManifestWithMetadata extends Manifest, so we can use it directly
const manifest = existingManifest || createManifest('Global', installPath);
// Copy skill directory with manifest tracking
try {
const { files } = copyDirectoryWithManifest(skillDir, targetDir, manifest);
// Save manifest with tracked files
saveManifest(manifest);
return {
success: true,
message: `Skill '${skillName}' installed to ${cliType}`,
filesTracked: files,
};
} catch (error) {
return { success: false, message: `Failed to install: ${(error as Error).message}` };
}
}
/**
* Skill Hub installation command
*/
export async function installSkillHubCommand(options: SkillHubInstallOptions): Promise<void> {
const version = getVersion();
showHeader(version);
// List mode
if (options.list) {
const skills = listLocalSkillHubSkills();
if (skills.length === 0) {
info('No local skills found in skill-hub');
info(`Add skills to: ${getLocalSkillsDir()}`);
return;
}
info(`Found ${skills.length} local skills in skill-hub:`);
console.log('');
for (const skill of skills) {
console.log(chalk.cyan(` ${skill.id}`));
console.log(chalk.gray(` Name: ${skill.name}`));
console.log(chalk.gray(` Version: ${skill.version}`));
if (skill.description) {
console.log(chalk.gray(` Description: ${skill.description}`));
}
console.log('');
}
info('To install a skill:');
console.log(chalk.gray(' ccw install --skill-hub local-skill-name --cli claude'));
return;
}
// Install mode
if (options.skillId) {
const cliType = options.cliType || 'claude';
const spinner = createSpinner(`Installing skill '${options.skillId}' to ${cliType}...`).start();
const result = await installSkillFromHub(options.skillId, cliType as 'claude' | 'codex');
if (result.success) {
spinner.succeed(result.message);
} else {
spinner.fail(result.message);
}
return;
}
// No options - show help
info('Skill Hub Installation');
console.log('');
console.log(chalk.gray('Usage:'));
console.log(chalk.cyan(' ccw install --skill-hub --list') + chalk.gray(' List available local skills'));
console.log(chalk.cyan(' ccw install --skill-hub <id> --cli <type>') + chalk.gray(' Install a skill'));
console.log('');
console.log(chalk.gray('Options:'));
console.log(chalk.gray(' --skill-hub, --skill Skill ID to install'));
console.log(chalk.gray(' --cli Target CLI (claude or codex, default: claude)'));
console.log(chalk.gray(' --list List available skills'));
}