From 92b9c408975c2aea5bc2cfe1ca2cc24f2a07ef62 Mon Sep 17 00:00:00 2001 From: li Date: Thu, 12 Mar 2026 13:16:14 +0800 Subject: [PATCH] refactor: deduplicate installer-mcp + decompose installWorkflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installer-mcp.ts (678→462): - Extract configureMcpInClaude() shared pipeline, eliminates 3x duplication - Extract mirrorCcgServers() + formatSyncMessage() for sync dedup - installAceTool/installAceToolRs/installFastContext now thin wrappers installer.ts — decompose installWorkflows (290-line monolith → 6 sub-functions): - installCommandFiles, installAgentFiles, installPromptFiles - installSkillFiles, installRuleFiles, installBinaryFile - Shared InstallContext interface passes state between steps Total: 1926→1724 lines (-202), dist 215→212 kB (-3 kB) --- src/utils/installer-mcp.ts | 550 ++++++++++---------------------- src/utils/installer.ts | 630 +++++++++++++++++++------------------ 2 files changed, 489 insertions(+), 691 deletions(-) diff --git a/src/utils/installer-mcp.ts b/src/utils/installer-mcp.ts index 4cd8745..09fb80e 100644 --- a/src/utils/installer-mcp.ts +++ b/src/utils/installer-mcp.ts @@ -6,6 +6,67 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml' import { backupClaudeCodeConfig, buildMcpServerConfig, fixWindowsMcpConfig, mergeMcpServers, readClaudeCodeConfig, writeClaudeCodeConfig } from './mcp' import { isWindows } from './platform' +// ═══════════════════════════════════════════════════════ +// Shared types & helpers +// ═══════════════════════════════════════════════════════ + +type McpInstallResult = { success: boolean, message: string, configPath?: string } + +/** + * Common pipeline for installing an MCP server into ~/.claude.json: + * read → backup → merge → Windows fix → write. + * + * All MCP installers funnel through this to avoid duplication. + */ +async function configureMcpInClaude( + serverId: string, + serverConfig: Record, + label: string, +): Promise { + try { + let existingConfig = await readClaudeCodeConfig() + if (!existingConfig) { + existingConfig = { mcpServers: {} } + } + + // Backup before modifying (if config exists) + if (existingConfig.mcpServers && Object.keys(existingConfig.mcpServers).length > 0) { + const backupPath = await backupClaudeCodeConfig() + if (backupPath) { + console.log(` ✓ Backup created: ${backupPath}`) + } + } + + // Merge new server into existing config + let mergedConfig = mergeMcpServers(existingConfig, { + [serverId]: serverConfig, + }) + + // Apply Windows fixes if needed + if (isWindows()) { + mergedConfig = fixWindowsMcpConfig(mergedConfig) + console.log(' ✓ Applied Windows MCP configuration fixes') + } + + // Write config back (preserve all other fields) + await writeClaudeCodeConfig(mergedConfig) + + return { + success: true, + message: isWindows() + ? `${label} configured successfully with Windows compatibility` + : `${label} configured successfully`, + configPath: join(homedir(), '.claude.json'), + } + } + catch (error) { + return { + success: false, + message: `Failed to configure ${label}: ${error}`, + } + } +} + // ═══════════════════════════════════════════════════════ // ace-tool MCP // ═══════════════════════════════════════════════════════ @@ -18,181 +79,56 @@ export async function uninstallAceTool(): Promise<{ success: boolean, message: s const existingConfig = await readClaudeCodeConfig() if (!existingConfig) { - return { - success: true, - message: 'No ~/.claude.json found, nothing to remove', - } + return { success: true, message: 'No ~/.claude.json found, nothing to remove' } } - // Check if ace-tool exists if (!existingConfig.mcpServers || !existingConfig.mcpServers['ace-tool']) { - return { - success: true, - message: 'ace-tool MCP not found in config', - } + return { success: true, message: 'ace-tool MCP not found in config' } } - // Backup before modifying await backupClaudeCodeConfig() - - // Remove ace-tool from mcpServers delete existingConfig.mcpServers['ace-tool'] - - // Write back await writeClaudeCodeConfig(existingConfig) - return { - success: true, - message: 'ace-tool MCP removed from ~/.claude.json', - } + return { success: true, message: 'ace-tool MCP removed from ~/.claude.json' } } catch (error) { - return { - success: false, - message: `Failed to uninstall ace-tool: ${error}`, - } + return { success: false, message: `Failed to uninstall ace-tool: ${error}` } } } /** * Install and configure ace-tool MCP for Claude Code. - * Writes to ~/.claude.json (the correct config file for Claude Code CLI). */ -export async function installAceTool(config: AceToolConfig): Promise<{ success: boolean, message: string, configPath?: string }> { +export async function installAceTool(config: AceToolConfig): Promise { const { baseUrl, token } = config - try { - // Read existing config or create new one - let existingConfig = await readClaudeCodeConfig() + const args = ['-y', 'ace-tool@latest'] + if (baseUrl) args.push('--base-url', baseUrl) + if (token) args.push('--token', token) - if (!existingConfig) { - existingConfig = { mcpServers: {} } - } - - // Backup before modifying (if config exists) - if (existingConfig.mcpServers && Object.keys(existingConfig.mcpServers).length > 0) { - const backupPath = await backupClaudeCodeConfig() - if (backupPath) { - console.log(` ✓ Backup created: ${backupPath}`) - } - } - - // Build args array (with -y flag for npx auto-confirm) - const args = ['-y', 'ace-tool@latest'] - if (baseUrl) { - args.push('--base-url', baseUrl) - } - if (token) { - args.push('--token', token) - } - - // Create base ace-tool MCP server config - const aceToolConfig = buildMcpServerConfig({ - type: 'stdio' as const, - command: 'npx', - args, - }) - - // Merge new server into existing config - let mergedConfig = mergeMcpServers(existingConfig, { - 'ace-tool': aceToolConfig, - }) - - // Apply Windows fixes if needed - if (isWindows()) { - mergedConfig = fixWindowsMcpConfig(mergedConfig) - console.log(' ✓ Applied Windows MCP configuration fixes') - } - - // Write config back (preserve all other fields) - await writeClaudeCodeConfig(mergedConfig) - - return { - success: true, - message: isWindows() - ? 'ace-tool MCP configured successfully with Windows compatibility' - : 'ace-tool MCP configured successfully', - configPath: join(homedir(), '.claude.json'), - } - } - catch (error) { - return { - success: false, - message: `Failed to configure ace-tool: ${error}`, - } - } + const serverConfig = buildMcpServerConfig({ type: 'stdio', command: 'npx', args }) + return configureMcpInClaude('ace-tool', serverConfig, 'ace-tool MCP') } /** * Install and configure ace-tool-rs MCP for Claude Code. - * ace-tool-rs is a Rust implementation of ace-tool, more lightweight and faster. + * ace-tool-rs is a Rust implementation — more lightweight and faster. */ -export async function installAceToolRs(config: AceToolConfig): Promise<{ success: boolean, message: string, configPath?: string }> { +export async function installAceToolRs(config: AceToolConfig): Promise { const { baseUrl, token } = config - try { - // Read existing config or create new one - let existingConfig = await readClaudeCodeConfig() + const args = ['ace-tool-rs'] + if (baseUrl) args.push('--base-url', baseUrl) + if (token) args.push('--token', token) - if (!existingConfig) { - existingConfig = { mcpServers: {} } - } - - // Backup before modifying (if config exists) - if (existingConfig.mcpServers && Object.keys(existingConfig.mcpServers).length > 0) { - const backupPath = await backupClaudeCodeConfig() - if (backupPath) { - console.log(` ✓ Backup created: ${backupPath}`) - } - } - - // Build args array for ace-tool-rs - const args = ['ace-tool-rs'] - if (baseUrl) { - args.push('--base-url', baseUrl) - } - if (token) { - args.push('--token', token) - } - - // Create base ace-tool-rs MCP server config - const aceToolRsConfig = buildMcpServerConfig({ - type: 'stdio' as const, - command: 'npx', - args, - env: { - RUST_LOG: 'info', - }, - }) - - // Merge new server into existing config - let mergedConfig = mergeMcpServers(existingConfig, { - 'ace-tool': aceToolRsConfig, - }) - - // Apply Windows fixes if needed - if (isWindows()) { - mergedConfig = fixWindowsMcpConfig(mergedConfig) - console.log(' ✓ Applied Windows MCP configuration fixes') - } - - // Write config back (preserve all other fields) - await writeClaudeCodeConfig(mergedConfig) - - return { - success: true, - message: isWindows() - ? 'ace-tool-rs MCP configured successfully with Windows compatibility' - : 'ace-tool-rs MCP configured successfully', - configPath: join(homedir(), '.claude.json'), - } - } - catch (error) { - return { - success: false, - message: `Failed to configure ace-tool-rs: ${error}`, - } - } + const serverConfig = buildMcpServerConfig({ + type: 'stdio', + command: 'npx', + args, + env: { RUST_LOG: 'info' }, + }) + return configureMcpInClaude('ace-tool', serverConfig, 'ace-tool-rs MCP') } // ═══════════════════════════════════════════════════════ @@ -210,7 +146,7 @@ export interface ContextWeaverConfig { * Install and configure ContextWeaver MCP for Claude Code. * ContextWeaver is a local-first semantic code search engine with hybrid search + rerank. */ -export async function installContextWeaver(config: ContextWeaverConfig): Promise<{ success: boolean, message: string, configPath?: string }> { +export async function installContextWeaver(config: ContextWeaverConfig): Promise { const { siliconflowApiKey } = config try { @@ -222,7 +158,6 @@ export async function installContextWeaver(config: ContextWeaverConfig): Promise console.log(' ✓ ContextWeaver CLI 安装成功') } catch { - // Try with sudo on Unix systems if (process.platform !== 'win32') { try { execSync('sudo npm install -g @hsingjui/contextweaver', { stdio: 'pipe' }) @@ -258,51 +193,16 @@ RERANK_TOP_N=20 ` await fs.writeFile(join(contextWeaverDir, '.env'), envContent, 'utf-8') - // 2. Read existing Claude Code config - let existingConfig = await readClaudeCodeConfig() - if (!existingConfig) { - existingConfig = { mcpServers: {} } - } - - // Backup before modifying - if (existingConfig.mcpServers && Object.keys(existingConfig.mcpServers).length > 0) { - const backupPath = await backupClaudeCodeConfig() - if (backupPath) { - console.log(` ✓ Backup created: ${backupPath}`) - } - } - - // 3. Build ContextWeaver MCP server config - const contextWeaverMcpConfig = buildMcpServerConfig({ - type: 'stdio' as const, + // 2. Configure MCP via shared pipeline + const serverConfig = buildMcpServerConfig({ + type: 'stdio', command: 'contextweaver', args: ['mcp'], }) - - // 4. Merge into existing config - let mergedConfig = mergeMcpServers(existingConfig, { - contextweaver: contextWeaverMcpConfig, - }) - - // Apply Windows fixes if needed - if (isWindows()) { - mergedConfig = fixWindowsMcpConfig(mergedConfig) - } - - // 5. Write config back - await writeClaudeCodeConfig(mergedConfig) - - return { - success: true, - message: 'ContextWeaver MCP configured successfully', - configPath: join(homedir(), '.claude.json'), - } + return await configureMcpInClaude('contextweaver', serverConfig, 'ContextWeaver MCP') } catch (error) { - return { - success: false, - message: `Failed to configure ContextWeaver: ${error}`, - } + return { success: false, message: `Failed to configure ContextWeaver: ${error}` } } } @@ -311,27 +211,15 @@ RERANK_TOP_N=20 */ export async function uninstallContextWeaver(): Promise<{ success: boolean, message: string }> { try { - // 1. Remove from claude.json const existingConfig = await readClaudeCodeConfig() if (existingConfig?.mcpServers?.contextweaver) { delete existingConfig.mcpServers.contextweaver await writeClaudeCodeConfig(existingConfig) } - - // 2. Optionally remove ~/.contextweaver directory (keep it for now, user might want to keep config) - // const contextWeaverDir = join(homedir(), '.contextweaver') - // await fs.remove(contextWeaverDir) - - return { - success: true, - message: 'ContextWeaver MCP uninstalled successfully', - } + return { success: true, message: 'ContextWeaver MCP uninstalled successfully' } } catch (error) { - return { - success: false, - message: `Failed to uninstall ContextWeaver: ${error}`, - } + return { success: false, message: `Failed to uninstall ContextWeaver: ${error}` } } } @@ -341,68 +229,21 @@ export async function uninstallContextWeaver(): Promise<{ success: boolean, mess /** * Install and configure Fast Context (Windsurf) MCP for Claude Code. - * Fast Context uses Windsurf's AI-powered code search — no full-repo indexing needed. */ -export async function installFastContext(config: FastContextConfig): Promise<{ success: boolean, message: string, configPath?: string }> { +export async function installFastContext(config: FastContextConfig): Promise { const { apiKey, includeSnippets } = config - try { - let existingConfig = await readClaudeCodeConfig() - if (!existingConfig) { - existingConfig = { mcpServers: {} } - } + const env: Record = {} + if (apiKey) env.WINDSURF_API_KEY = apiKey + if (includeSnippets) env.FC_INCLUDE_SNIPPETS = 'true' - // Backup before modifying - if (existingConfig.mcpServers && Object.keys(existingConfig.mcpServers).length > 0) { - const backupPath = await backupClaudeCodeConfig() - if (backupPath) { - console.log(` ✓ Backup created: ${backupPath}`) - } - } - - // Build env - const env: Record = {} - if (apiKey) { - env.WINDSURF_API_KEY = apiKey - } - if (includeSnippets) { - env.FC_INCLUDE_SNIPPETS = 'true' - } - - // Build fast-context MCP server config - const fastContextConfig = buildMcpServerConfig({ - type: 'stdio' as const, - command: 'npx', - args: ['-y', '--prefer-online', 'fast-context-mcp@latest'], - ...(Object.keys(env).length > 0 ? { env } : {}), - }) - - // Merge into existing config - let mergedConfig = mergeMcpServers(existingConfig, { - 'fast-context': fastContextConfig, - }) - - if (isWindows()) { - mergedConfig = fixWindowsMcpConfig(mergedConfig) - console.log(' ✓ Applied Windows MCP configuration fixes') - } - - await writeClaudeCodeConfig(mergedConfig) - - return { - success: true, - message: isWindows() - ? 'fast-context MCP configured successfully with Windows compatibility' - : 'fast-context MCP configured successfully', - configPath: join(homedir(), '.claude.json'), - } - } - catch (error) { - return { - success: false, - message: `Failed to configure fast-context: ${error}`, - } - } + const serverConfig = buildMcpServerConfig({ + type: 'stdio', + command: 'npx', + args: ['-y', '--prefer-online', 'fast-context-mcp@latest'], + ...(Object.keys(env).length > 0 ? { env } : {}), + }) + return configureMcpInClaude('fast-context', serverConfig, 'fast-context MCP') } /** @@ -415,17 +256,10 @@ export async function uninstallFastContext(): Promise<{ success: boolean, messag delete existingConfig.mcpServers['fast-context'] await writeClaudeCodeConfig(existingConfig) } - - return { - success: true, - message: 'fast-context MCP uninstalled successfully', - } + return { success: true, message: 'fast-context MCP uninstalled successfully' } } catch (error) { - return { - success: false, - message: `Failed to uninstall fast-context: ${error}`, - } + return { success: false, message: `Failed to uninstall fast-context: ${error}` } } } @@ -442,24 +276,8 @@ export async function installMcpServer( args: string[], env: Record = {}, ): Promise<{ success: boolean, message: string }> { - try { - await backupClaudeCodeConfig() - const existingConfig = await readClaudeCodeConfig() - - const serverConfig = buildMcpServerConfig({ type: 'stdio', command, args, env }) - - let mergedConfig = mergeMcpServers(existingConfig, { [id]: serverConfig }) - if (isWindows()) { - mergedConfig = fixWindowsMcpConfig(mergedConfig) - } - - await writeClaudeCodeConfig(mergedConfig) - - return { success: true, message: `${id} MCP installed successfully` } - } - catch (error) { - return { success: false, message: `Failed to install ${id}: ${error}` } - } + const serverConfig = buildMcpServerConfig({ type: 'stdio', command, args, env }) + return configureMcpInClaude(id, serverConfig, id) } /** @@ -472,7 +290,6 @@ export async function uninstallMcpServer(id: string): Promise<{ success: boolean delete existingConfig.mcpServers[id] await writeClaudeCodeConfig(existingConfig) } - return { success: true, message: `${id} MCP uninstalled successfully` } } catch (error) { @@ -495,9 +312,10 @@ const CCG_MCP_IDS = new Set([ 'fast-context', ]) +type SyncResult = { success: boolean, message: string, synced: string[], removed: string[] } + /** * Read Claude's MCP config and filter to CCG-managed servers. - * Shared helper for syncMcpToCodex and syncMcpToGemini. */ async function getCcgMcpServersFromClaude(): Promise> { const claudeConfig = await readClaudeCodeConfig() @@ -513,29 +331,55 @@ async function getCcgMcpServersFromClaude(): Promise> { } /** - * Sync (mirror) CCG-managed MCP servers from Claude's ~/.claude.json - * to Codex's ~/.codex/config.toml - * - * - Only touches servers in CCG_MCP_IDS — user's custom MCP servers are untouched. - * - Servers present in Claude are added/updated with ALL fields (not just command/args/env). - * - Servers absent from Claude but present in Codex (within CCG_MCP_IDS) are REMOVED. - * - Preserves all existing Codex config (model_provider, model, etc.) - * - Uses atomic write (temp file + rename) to prevent corruption. + * Apply mirror logic: add/update servers from Claude, remove stale CCG servers. + * Returns { synced, removed } arrays. Mutates targetServers in place. */ -export async function syncMcpToCodex(): Promise<{ - success: boolean - message: string - synced: string[] - removed: string[] -}> { +function mirrorCcgServers( + serversToSync: Record, + targetServers: Record, +): { synced: string[], removed: string[] } { const synced: string[] = [] const removed: string[] = [] + // Add/update CCG servers + for (const [id, claudeServer] of Object.entries(serversToSync)) { + targetServers[id] = claudeServer + synced.push(id) + } + + // Remove CCG servers that no longer exist in Claude + for (const id of CCG_MCP_IDS) { + if (!serversToSync[id] && targetServers[id]) { + delete targetServers[id] + removed.push(id) + } + } + + return { synced, removed } +} + +/** + * Format sync result message + */ +function formatSyncMessage(target: string, synced: string[], removed: string[]): string { + const parts: string[] = [] + if (synced.length > 0) parts.push(`synced: ${synced.join(', ')}`) + if (removed.length > 0) parts.push(`removed: ${removed.join(', ')}`) + return `${target} MCP mirror complete (${parts.join('; ')})` +} + +/** + * Sync (mirror) CCG-managed MCP servers from Claude's ~/.claude.json + * to Codex's ~/.codex/config.toml + * + * - Only touches servers in CCG_MCP_IDS — user's custom servers untouched. + * - Uses atomic write (temp file + rename) to prevent corruption. + */ +export async function syncMcpToCodex(): Promise { try { - // 1. Read Claude's CCG-managed MCP config const serversToSync = await getCcgMcpServersFromClaude() - // 2. Read or create Codex config + // Read or create Codex config const codexConfigDir = join(homedir(), '.codex') const codexConfigPath = join(codexConfigDir, 'config.toml') await fs.ensureDir(codexConfigDir) @@ -546,91 +390,49 @@ export async function syncMcpToCodex(): Promise<{ codexConfig = parseToml(content) as Record } - // 3. Ensure mcp_servers table exists if (!codexConfig.mcp_servers) { codexConfig.mcp_servers = {} } - // 4. Mirror: add/update CCG servers (pass through ALL fields) - for (const [id, claudeServer] of Object.entries(serversToSync)) { - const server = claudeServer as Record - const codexEntry: Record = {} - - // Pass through all TOML-compatible fields from Claude config - for (const [key, value] of Object.entries(server)) { + // Codex needs field-level copy (TOML compatibility: filter null/undefined) + const codexServersToSync: Record = {} + for (const [id, server] of Object.entries(serversToSync)) { + const entry: Record = {} + for (const [key, value] of Object.entries(server as Record)) { if (value !== null && value !== undefined) { - codexEntry[key] = value + entry[key] = value } } - - codexConfig.mcp_servers[id] = codexEntry - synced.push(id) + codexServersToSync[id] = entry } - // 5. Mirror: remove CCG servers that no longer exist in Claude - for (const id of CCG_MCP_IDS) { - if (!serversToSync[id] && codexConfig.mcp_servers[id]) { - delete codexConfig.mcp_servers[id] - removed.push(id) - } - } + const { synced, removed } = mirrorCcgServers(codexServersToSync, codexConfig.mcp_servers) - // Skip write if no changes needed if (synced.length === 0 && removed.length === 0) { - return { - success: true, - message: 'No CCG MCP servers to sync or remove', - synced: [], - removed: [], - } + return { success: true, message: 'No CCG MCP servers to sync or remove', synced: [], removed: [] } } - // 6. Atomic write: temp file + rename to prevent corruption + // Atomic write: temp file + rename const tmpPath = `${codexConfigPath}.tmp` await fs.writeFile(tmpPath, stringifyToml(codexConfig), 'utf-8') await fs.rename(tmpPath, codexConfigPath) - const parts: string[] = [] - if (synced.length > 0) parts.push(`synced: ${synced.join(', ')}`) - if (removed.length > 0) parts.push(`removed: ${removed.join(', ')}`) - - return { - success: true, - message: `Codex MCP mirror complete (${parts.join('; ')})`, - synced, - removed, - } + return { success: true, message: formatSyncMessage('Codex', synced, removed), synced, removed } } catch (error) { - return { - success: false, - message: `Failed to sync MCP to Codex: ${error}`, - synced, - removed, - } + return { success: false, message: `Failed to sync MCP to Codex: ${error}`, synced: [], removed: [] } } } /** * Sync (mirror) CCG-managed MCP servers from Claude's ~/.claude.json * to Gemini CLI's ~/.gemini/settings.json - * - * Same logic as syncMcpToCodex but targets Gemini's JSON config format. */ -export async function syncMcpToGemini(): Promise<{ - success: boolean - message: string - synced: string[] - removed: string[] -}> { - const synced: string[] = [] - const removed: string[] = [] - +export async function syncMcpToGemini(): Promise { try { - // 1. Read Claude's CCG-managed MCP config const serversToSync = await getCcgMcpServersFromClaude() - // 2. Read or create Gemini settings + // Read or create Gemini settings const geminiDir = join(homedir(), '.gemini') const geminiSettingsPath = join(geminiDir, 'settings.json') await fs.ensureDir(geminiDir) @@ -640,39 +442,21 @@ export async function syncMcpToGemini(): Promise<{ geminiSettings = await fs.readJSON(geminiSettingsPath) } - // 3. Ensure mcpServers exists if (!geminiSettings.mcpServers) { geminiSettings.mcpServers = {} } - // 4. Mirror: add/update CCG servers - for (const [id, claudeServer] of Object.entries(serversToSync)) { - geminiSettings.mcpServers[id] = claudeServer - synced.push(id) - } - - // 5. Mirror: remove CCG servers that no longer exist in Claude - for (const id of CCG_MCP_IDS) { - if (!serversToSync[id] && geminiSettings.mcpServers[id]) { - delete geminiSettings.mcpServers[id] - removed.push(id) - } - } + const { synced, removed } = mirrorCcgServers(serversToSync, geminiSettings.mcpServers) if (synced.length === 0 && removed.length === 0) { return { success: true, message: 'No CCG MCP servers to sync to Gemini', synced: [], removed: [] } } - // 6. Write back (preserve all other Gemini settings) await fs.writeJSON(geminiSettingsPath, geminiSettings, { spaces: 2 }) - const parts: string[] = [] - if (synced.length > 0) parts.push(`synced: ${synced.join(', ')}`) - if (removed.length > 0) parts.push(`removed: ${removed.join(', ')}`) - - return { success: true, message: `Gemini MCP mirror complete (${parts.join('; ')})`, synced, removed } + return { success: true, message: formatSyncMessage('Gemini', synced, removed), synced, removed } } catch (error) { - return { success: false, message: `Failed to sync MCP to Gemini: ${error}`, synced, removed } + return { success: false, message: `Failed to sync MCP to Gemini: ${error}`, synced: [], removed: [] } } } diff --git a/src/utils/installer.ts b/src/utils/installer.ts index b17c820..9861cdc 100644 --- a/src/utils/installer.ts +++ b/src/utils/installer.ts @@ -3,7 +3,6 @@ import fs from 'fs-extra' import { join } from 'pathe' import { getWorkflowById } from './installer-data' import { PACKAGE_ROOT, injectConfigVariables, replaceHomePathsInTemplate } from './installer-template' -import { isWindows } from './platform' // ═══════════════════════════════════════════════════════ // Re-exports — all consumers import from './installer' @@ -41,27 +40,45 @@ export { writeFastContextPrompt, } from './installer-prompt' +// ═══════════════════════════════════════════════════════ +// Install context — shared across sub-functions +// ═══════════════════════════════════════════════════════ + +interface InstallConfig { + routing: { + mode: string + frontend: { models: string[], primary: string } + backend: { models: string[], primary: string } + review: { models: string[] } + } + liteMode: boolean + mcpProvider: string +} + +interface InstallContext { + installDir: string + force: boolean + config: InstallConfig + templateDir: string + result: InstallResult +} + // ═══════════════════════════════════════════════════════ // Binary download // ═══════════════════════════════════════════════════════ -// GitHub Release download config const GITHUB_REPO = 'fengshao1227/ccg-workflow' const RELEASE_TAG = 'preset' const BINARY_DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}` /** * Download codeagent-wrapper binary from GitHub Release. - * Uses the fixed `preset` tag release for pre-compiled binaries. - * Supports redirect-following (GitHub releases redirect to CDN). - * - * Retry: 3 attempts with exponential backoff (2s, 4s, 6s). - * Timeout: 60s per attempt via AbortController. + * Retry: 3 attempts with exponential backoff. Timeout: 60s per attempt. */ async function downloadBinaryFromRelease(binaryName: string, destPath: string): Promise { const url = `${BINARY_DOWNLOAD_URL}/${binaryName}` const MAX_ATTEMPTS = 3 - const TIMEOUT_MS = 60_000 // 60s per attempt + const TIMEOUT_MS = 60_000 for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { @@ -82,17 +99,13 @@ async function downloadBinaryFromRelease(binaryName: string, destPath: string): clearTimeout(timer) await fs.writeFile(destPath, buffer) - - // Set executable permission on Unix-like systems if (process.platform !== 'win32') { await fs.chmod(destPath, 0o755) } - return true } catch { if (attempt < MAX_ATTEMPTS) { - // Exponential backoff before retry await new Promise(resolve => setTimeout(resolve, attempt * 2000)) continue } @@ -104,12 +117,129 @@ async function downloadBinaryFromRelease(binaryName: string, destPath: string): } // ═══════════════════════════════════════════════════════ -// Skills counter +// Install sub-steps // ═══════════════════════════════════════════════════════ /** - * Count installed SKILL.md files in the skills directory (recursive). - * Excludes root-level SKILL.md (which is the index/manifest, not a skill itself). + * Install slash command .md files from templates/commands/ + */ +async function installCommandFiles(ctx: InstallContext, workflowIds: string[]): Promise { + const commandsDir = join(ctx.installDir, 'commands', 'ccg') + + for (const workflowId of workflowIds) { + const workflow = getWorkflowById(workflowId) + if (!workflow) { + ctx.result.errors.push(`Unknown workflow: ${workflowId}`) + continue + } + + for (const cmd of workflow.commands) { + const srcFile = join(ctx.templateDir, 'commands', `${cmd}.md`) + const destFile = join(commandsDir, `${cmd}.md`) + + try { + if (await fs.pathExists(srcFile)) { + if (ctx.force || !(await fs.pathExists(destFile))) { + let content = await fs.readFile(srcFile, 'utf-8') + content = injectConfigVariables(content, ctx.config) + content = replaceHomePathsInTemplate(content, ctx.installDir) + await fs.writeFile(destFile, content, 'utf-8') + ctx.result.installedCommands.push(cmd) + } + } + else { + const placeholder = `--- +description: "${workflow.descriptionEn}" +--- + +# /ccg:${cmd} + +${workflow.description} + +> This command is part of CCG multi-model collaboration system. +` + await fs.writeFile(destFile, placeholder, 'utf-8') + ctx.result.installedCommands.push(cmd) + } + } + catch (error) { + ctx.result.errors.push(`Failed to install ${cmd}: ${error}`) + ctx.result.success = false + } + } + } +} + +/** + * Install agent .md files from templates/commands/agents/ + */ +async function installAgentFiles(ctx: InstallContext): Promise { + const agentsSrcDir = join(ctx.templateDir, 'commands', 'agents') + const agentsDestDir = join(ctx.installDir, 'agents', 'ccg') + if (!(await fs.pathExists(agentsSrcDir))) return + + try { + await fs.ensureDir(agentsDestDir) + const agentFiles = await fs.readdir(agentsSrcDir) + for (const file of agentFiles) { + if (file.endsWith('.md')) { + const srcFile = join(agentsSrcDir, file) + const destFile = join(agentsDestDir, file) + if (ctx.force || !(await fs.pathExists(destFile))) { + let content = await fs.readFile(srcFile, 'utf-8') + content = injectConfigVariables(content, ctx.config) + content = replaceHomePathsInTemplate(content, ctx.installDir) + await fs.writeFile(destFile, content, 'utf-8') + } + } + } + } + catch (error) { + ctx.result.errors.push(`Failed to install agents: ${error}`) + ctx.result.success = false + } +} + +/** + * Install expert prompt .md files from templates/prompts/{codex,gemini,claude}/ + */ +async function installPromptFiles(ctx: InstallContext): Promise { + const promptsTemplateDir = join(ctx.templateDir, 'prompts') + const promptsDir = join(ctx.installDir, '.ccg', 'prompts') + if (!(await fs.pathExists(promptsTemplateDir))) return + + const modelDirs = ['codex', 'gemini', 'claude'] + for (const model of modelDirs) { + const srcModelDir = join(promptsTemplateDir, model) + const destModelDir = join(promptsDir, model) + + if (await fs.pathExists(srcModelDir)) { + try { + await fs.ensureDir(destModelDir) + const files = await fs.readdir(srcModelDir) + for (const file of files) { + if (file.endsWith('.md')) { + const srcFile = join(srcModelDir, file) + const destFile = join(destModelDir, file) + if (ctx.force || !(await fs.pathExists(destFile))) { + const content = await fs.readFile(srcFile, 'utf-8') + const processed = replaceHomePathsInTemplate(content, ctx.installDir) + await fs.writeFile(destFile, processed, 'utf-8') + ctx.result.installedPrompts.push(`${model}/${file.replace('.md', '')}`) + } + } + } + } + catch (error) { + ctx.result.errors.push(`Failed to install ${model} prompts: ${error}`) + ctx.result.success = false + } + } + } +} + +/** + * Count installed SKILL.md files (recursive, excludes root-level). */ async function countInstalledSkills(skillsDir: string, depth = 0): Promise { let count = 0 @@ -125,14 +255,150 @@ async function countInstalledSkills(skillsDir: string, depth = 0): Promise { + const skillsTemplateDir = join(ctx.templateDir, 'skills') + const skillsDestDir = join(ctx.installDir, 'skills', 'ccg') + if (!(await fs.pathExists(skillsTemplateDir))) return + + try { + // Migration: move old v1.7.73 layout into skills/ccg/ namespace + const oldSkillsRoot = join(ctx.installDir, 'skills') + const ccgLegacyItems = ['tools', 'orchestration', 'SKILL.md', 'run_skill.js'] + const needsMigration = !await fs.pathExists(skillsDestDir) + && await fs.pathExists(join(oldSkillsRoot, 'tools')) + if (needsMigration) { + await fs.ensureDir(skillsDestDir) + for (const item of ccgLegacyItems) { + const oldPath = join(oldSkillsRoot, item) + const newPath = join(skillsDestDir, item) + if (await fs.pathExists(oldPath)) { + await fs.move(oldPath, newPath, { overwrite: true }) + } + } + } + + // Recursive copy: preserves full directory tree + await fs.copy(skillsTemplateDir, skillsDestDir, { + overwrite: ctx.force, + errorOnExist: false, + }) + + // Post-copy: apply template variable replacement to .md files + const replacePathsInDir = async (dir: string): Promise => { + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + await replacePathsInDir(fullPath) + } + else if (entry.name.endsWith('.md')) { + const content = await fs.readFile(fullPath, 'utf-8') + const processed = replaceHomePathsInTemplate(content, ctx.installDir) + if (processed !== content) { + await fs.writeFile(fullPath, processed, 'utf-8') + } + } + } + } + await replacePathsInDir(skillsDestDir) + + ctx.result.installedSkills = await countInstalledSkills(skillsDestDir) + } + catch (error) { + ctx.result.errors.push(`Failed to install skills: ${error}`) + ctx.result.success = false + } +} + +/** + * Install rule .md files from templates/rules/ → ~/.claude/rules/ + */ +async function installRuleFiles(ctx: InstallContext): Promise { + const rulesTemplateDir = join(ctx.templateDir, 'rules') + const rulesDestDir = join(ctx.installDir, 'rules') + if (!(await fs.pathExists(rulesTemplateDir))) return + + try { + await fs.ensureDir(rulesDestDir) + const rulesFiles = await fs.readdir(rulesTemplateDir) + for (const file of rulesFiles) { + if (file.endsWith('.md')) { + const srcFile = join(rulesTemplateDir, file) + const destFile = join(rulesDestDir, file) + if (ctx.force || !(await fs.pathExists(destFile))) { + const content = await fs.readFile(srcFile, 'utf-8') + const processed = replaceHomePathsInTemplate(content, ctx.installDir) + await fs.writeFile(destFile, processed, 'utf-8') + } + } + } + ctx.result.installedRules = true + } + catch (error) { + ctx.result.errors.push(`Failed to install rules: ${error}`) + } +} + +/** + * Download and install codeagent-wrapper binary for current platform. + */ +async function installBinaryFile(ctx: InstallContext): Promise { + try { + const binDir = join(ctx.installDir, 'bin') + await fs.ensureDir(binDir) + + const platform = process.platform + const arch = process.arch + let binaryName: string + + if (platform === 'darwin') { + binaryName = arch === 'arm64' ? 'codeagent-wrapper-darwin-arm64' : 'codeagent-wrapper-darwin-amd64' + } + else if (platform === 'linux') { + binaryName = arch === 'arm64' ? 'codeagent-wrapper-linux-arm64' : 'codeagent-wrapper-linux-amd64' + } + else if (platform === 'win32') { + binaryName = arch === 'arm64' ? 'codeagent-wrapper-windows-arm64.exe' : 'codeagent-wrapper-windows-amd64.exe' + } + else { + ctx.result.errors.push(`Unsupported platform: ${platform}`) + ctx.result.success = false + return + } + + const destBinary = join(binDir, platform === 'win32' ? 'codeagent-wrapper.exe' : 'codeagent-wrapper') + const installed = await downloadBinaryFromRelease(binaryName, destBinary) + + if (installed) { + try { + const { execSync } = await import('node:child_process') + execSync(`"${destBinary}" --version`, { stdio: 'pipe' }) + ctx.result.binPath = binDir + ctx.result.binInstalled = true + } + catch (verifyError) { + ctx.result.errors.push(`Binary verification failed (non-blocking): ${verifyError}`) + } + } + else { + ctx.result.errors.push(`Failed to download binary: ${binaryName} from GitHub Release (after 3 attempts). Check network or visit https://github.com/${GITHUB_REPO}/releases/tag/${RELEASE_TAG}`) + } + } + catch (error) { + ctx.result.errors.push(`Failed to install codeagent-wrapper (non-blocking): ${error}`) + } +} + // ═══════════════════════════════════════════════════════ -// Core install / uninstall +// Public API: install / uninstall // ═══════════════════════════════════════════════════════ export async function installWorkflows( @@ -150,281 +416,44 @@ export async function installWorkflows( mcpProvider?: string }, ): Promise { - // Default config - const installConfig = { - routing: config?.routing || { - mode: 'smart', - frontend: { models: ['gemini'], primary: 'gemini' }, - backend: { models: ['codex'], primary: 'codex' }, - review: { models: ['codex', 'gemini'] }, + const ctx: InstallContext = { + installDir, + force, + config: { + routing: config?.routing as InstallConfig['routing'] || { + mode: 'smart', + frontend: { models: ['gemini'], primary: 'gemini' }, + backend: { models: ['codex'], primary: 'codex' }, + review: { models: ['codex', 'gemini'] }, + }, + liteMode: config?.liteMode || false, + mcpProvider: config?.mcpProvider || 'ace-tool', + }, + templateDir: join(PACKAGE_ROOT, 'templates'), + result: { + success: true, + installedCommands: [], + installedPrompts: [], + errors: [], + configPath: '', }, - liteMode: config?.liteMode || false, - mcpProvider: config?.mcpProvider || 'ace-tool', - } - const result: InstallResult = { - success: true, - installedCommands: [], - installedPrompts: [], - errors: [], - configPath: '', } - const commandsDir = join(installDir, 'commands', 'ccg') - const ccgConfigDir = join(installDir, '.ccg') // v1.4.0: 配置目录 - const promptsDir = join(ccgConfigDir, 'prompts') // v1.4.0: prompts 移到配置目录 + // Ensure base directories + await fs.ensureDir(join(installDir, 'commands', 'ccg')) + await fs.ensureDir(join(installDir, '.ccg')) + await fs.ensureDir(join(installDir, '.ccg', 'prompts')) - await fs.ensureDir(commandsDir) - await fs.ensureDir(ccgConfigDir) - await fs.ensureDir(promptsDir) + // Execute each install step + await installCommandFiles(ctx, workflowIds) + await installAgentFiles(ctx) + await installPromptFiles(ctx) + await installSkillFiles(ctx) + await installRuleFiles(ctx) + await installBinaryFile(ctx) - // Get template source directory (relative to this package) - const templateDir = join(PACKAGE_ROOT, 'templates') - - // Install commands - for (const workflowId of workflowIds) { - const workflow = getWorkflowById(workflowId) - if (!workflow) { - result.errors.push(`Unknown workflow: ${workflowId}`) - continue - } - - for (const cmd of workflow.commands) { - const srcFile = join(templateDir, 'commands', `${cmd}.md`) - const destFile = join(commandsDir, `${cmd}.md`) - - try { - if (await fs.pathExists(srcFile)) { - if (force || !(await fs.pathExists(destFile))) { - // Read template content, inject config variables, replace ~ paths, then write - let templateContent = await fs.readFile(srcFile, 'utf-8') - templateContent = injectConfigVariables(templateContent, installConfig) - const processedContent = replaceHomePathsInTemplate(templateContent, installDir) - await fs.writeFile(destFile, processedContent, 'utf-8') - result.installedCommands.push(cmd) - } - } - else { - // If template doesn't exist, create placeholder - const placeholder = `--- -description: "${workflow.descriptionEn}" ---- - -# /ccg:${cmd} - -${workflow.description} - -> This command is part of CCG multi-model collaboration system. -` - await fs.writeFile(destFile, placeholder, 'utf-8') - result.installedCommands.push(cmd) - } - } - catch (error) { - result.errors.push(`Failed to install ${cmd}: ${error}`) - result.success = false - } - } - } - - // Install agents directory (subagents - should go to ~/.claude/agents/ccg/) - const agentsSrcDir = join(templateDir, 'commands', 'agents') - const agentsDestDir = join(installDir, 'agents', 'ccg') - if (await fs.pathExists(agentsSrcDir)) { - try { - await fs.ensureDir(agentsDestDir) - const agentFiles = await fs.readdir(agentsSrcDir) - for (const file of agentFiles) { - if (file.endsWith('.md')) { - const srcFile = join(agentsSrcDir, file) - const destFile = join(agentsDestDir, file) - if (force || !(await fs.pathExists(destFile))) { - // Read template content, inject config variables, replace ~ paths, then write - let templateContent = await fs.readFile(srcFile, 'utf-8') - templateContent = injectConfigVariables(templateContent, installConfig) - const processedContent = replaceHomePathsInTemplate(templateContent, installDir) - await fs.writeFile(destFile, processedContent, 'utf-8') - } - } - } - } - catch (error) { - result.errors.push(`Failed to install agents: ${error}`) - result.success = false - } - } - - // Install prompts (codex, gemini, claude role definitions) - const promptsTemplateDir = join(templateDir, 'prompts') - if (await fs.pathExists(promptsTemplateDir)) { - const modelDirs = ['codex', 'gemini', 'claude'] - for (const model of modelDirs) { - const srcModelDir = join(promptsTemplateDir, model) - const destModelDir = join(promptsDir, model) - - if (await fs.pathExists(srcModelDir)) { - try { - await fs.ensureDir(destModelDir) - const files = await fs.readdir(srcModelDir) - for (const file of files) { - if (file.endsWith('.md')) { - const srcFile = join(srcModelDir, file) - const destFile = join(destModelDir, file) - if (force || !(await fs.pathExists(destFile))) { - // Read template content, replace ~ paths, then write - const templateContent = await fs.readFile(srcFile, 'utf-8') - const processedContent = replaceHomePathsInTemplate(templateContent, installDir) - await fs.writeFile(destFile, processedContent, 'utf-8') - result.installedPrompts.push(`${model}/${file.replace('.md', '')}`) - } - } - } - } - catch (error) { - result.errors.push(`Failed to install ${model} prompts: ${error}`) - result.success = false - } - } - } - } - - // Install skills (quality gates, multi-agent orchestration, etc. → ~/.claude/skills/ccg/) - // Uses 'ccg' namespace to avoid clobbering user's own skills on uninstall - const skillsTemplateDir = join(templateDir, 'skills') - const skillsDestDir = join(installDir, 'skills', 'ccg') - if (await fs.pathExists(skillsTemplateDir)) { - try { - // Migration: move old v1.7.73 layout (skills/{tools,orchestration,SKILL.md,run_skill.js}) - // into skills/ccg/ namespace, preserving user's own skills in skills/ - const oldSkillsRoot = join(installDir, 'skills') - const ccgLegacyItems = ['tools', 'orchestration', 'SKILL.md', 'run_skill.js'] - const needsMigration = !await fs.pathExists(skillsDestDir) - && await fs.pathExists(join(oldSkillsRoot, 'tools')) - if (needsMigration) { - await fs.ensureDir(skillsDestDir) - for (const item of ccgLegacyItems) { - const oldPath = join(oldSkillsRoot, item) - const newPath = join(skillsDestDir, item) - if (await fs.pathExists(oldPath)) { - await fs.move(oldPath, newPath, { overwrite: true }) - } - } - } - - // Recursive copy: preserves full directory tree (tools/lib, tools/*/scripts/, orchestration/*) - await fs.copy(skillsTemplateDir, skillsDestDir, { - overwrite: force, - errorOnExist: false, - }) - - // Post-copy: apply template variable replacement to .md files - // (handles custom installDir where ~/.claude paths need substitution) - const replacePathsInDir = async (dir: string): Promise => { - const entries = await fs.readdir(dir, { withFileTypes: true }) - for (const entry of entries) { - const fullPath = join(dir, entry.name) - if (entry.isDirectory()) { - await replacePathsInDir(fullPath) - } - else if (entry.name.endsWith('.md')) { - const content = await fs.readFile(fullPath, 'utf-8') - const processed = replaceHomePathsInTemplate(content, installDir) - if (processed !== content) { - await fs.writeFile(fullPath, processed, 'utf-8') - } - } - } - } - await replacePathsInDir(skillsDestDir) - - result.installedSkills = await countInstalledSkills(skillsDestDir) - } - catch (error) { - result.errors.push(`Failed to install skills: ${error}`) - result.success = false - } - } - - // Install rules (quality gate auto-trigger → ~/.claude/rules/ccg-skills.md) - const rulesTemplateDir = join(templateDir, 'rules') - const rulesDestDir = join(installDir, 'rules') - if (await fs.pathExists(rulesTemplateDir)) { - try { - await fs.ensureDir(rulesDestDir) - const rulesFiles = await fs.readdir(rulesTemplateDir) - for (const file of rulesFiles) { - if (file.endsWith('.md')) { - const srcFile = join(rulesTemplateDir, file) - const destFile = join(rulesDestDir, file) - if (force || !(await fs.pathExists(destFile))) { - const templateContent = await fs.readFile(srcFile, 'utf-8') - const processedContent = replaceHomePathsInTemplate(templateContent, installDir) - await fs.writeFile(destFile, processedContent, 'utf-8') - } - } - } - result.installedRules = true - } - catch (error) { - result.errors.push(`Failed to install rules: ${error}`) - } - } - - // Install codeagent-wrapper binary - try { - const binDir = join(installDir, 'bin') - await fs.ensureDir(binDir) - - // Detect platform and select appropriate binary - const platform = process.platform - const arch = process.arch - let binaryName: string - - if (platform === 'darwin') { - binaryName = arch === 'arm64' ? 'codeagent-wrapper-darwin-arm64' : 'codeagent-wrapper-darwin-amd64' - } - else if (platform === 'linux') { - binaryName = arch === 'arm64' ? 'codeagent-wrapper-linux-arm64' : 'codeagent-wrapper-linux-amd64' - } - else if (platform === 'win32') { - binaryName = arch === 'arm64' ? 'codeagent-wrapper-windows-arm64.exe' : 'codeagent-wrapper-windows-amd64.exe' - } - else { - result.errors.push(`Unsupported platform: ${platform}`) - result.success = false - result.configPath = commandsDir - return result - } - - const destBinary = join(binDir, platform === 'win32' ? 'codeagent-wrapper.exe' : 'codeagent-wrapper') - - // Download from GitHub Release (3 attempts with retry) - const installed = await downloadBinaryFromRelease(binaryName, destBinary) - - if (installed) { - // Verify installation by running --version - try { - const { execSync } = await import('node:child_process') - execSync(`"${destBinary}" --version`, { stdio: 'pipe' }) - result.binPath = binDir - result.binInstalled = true - } - catch (verifyError) { - // Binary downloaded but verification failed — warning, not blocking - result.errors.push(`Binary verification failed (non-blocking): ${verifyError}`) - } - } - else { - // Binary download failed — warning, not blocking (commands + skills still work) - result.errors.push(`Failed to download binary: ${binaryName} from GitHub Release (after 3 attempts). Check network or visit https://github.com/${GITHUB_REPO}/releases/tag/${RELEASE_TAG}`) - } - } - catch (error) { - // Binary install exception — warning, not blocking - result.errors.push(`Failed to install codeagent-wrapper (non-blocking): ${error}`) - } - - result.configPath = commandsDir - return result + ctx.result.configPath = join(installDir, 'commands', 'ccg') + return ctx.result } // ═══════════════════════════════════════════════════════ @@ -458,7 +487,6 @@ export async function uninstallWorkflows(installDir: string): Promise => { const names: string[] = [] const entries = await fs.readdir(dir, { withFileTypes: true }) @@ -515,7 +536,6 @@ export async function uninstallWorkflows(installDir: string): Promise 0) { - // Use parent directory name as skill name const parts = dir.split('/') names.push(parts[parts.length - 1]) } @@ -523,8 +543,6 @@ export async function uninstallWorkflows(installDir: string): Promise