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 { 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} - Selected mode */ async function selectMode(): Promise { 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} - Selected path */ async function selectPath(): Promise { 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} - Selected config file path */ async function selectShellConfig(): Promise { 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 { 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 { 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 { 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 --cli ') + 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')); }