refactor: deduplicate installer-mcp + decompose installWorkflows
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)
This commit is contained in:
1 parent
1d31302a45
commit
92b9c40897
2 files changed
+489
-691
No files matched your search
+167
-383
@@ -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<string, any>,
|
||||
label: string,
|
||||
): Promise<McpInstallResult> {
|
||||
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<McpInstallResult> {
|
||||
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<McpInstallResult> {
|
||||
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<McpInstallResult> {
|
||||
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<McpInstallResult> {
|
||||
const { apiKey, includeSnippets } = config
|
||||
|
||||
try {
|
||||
let existingConfig = await readClaudeCodeConfig()
|
||||
if (!existingConfig) {
|
||||
existingConfig = { mcpServers: {} }
|
||||
}
|
||||
const env: Record<string, string> = {}
|
||||
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<string, string> = {}
|
||||
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<string, string> = {},
|
||||
): 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<Record<string, any>> {
|
||||
const claudeConfig = await readClaudeCodeConfig()
|
||||
@@ -513,29 +331,55 @@ async function getCcgMcpServersFromClaude(): Promise<Record<string, any>> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, any>,
|
||||
targetServers: Record<string, any>,
|
||||
): { 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<SyncResult> {
|
||||
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<string, any>
|
||||
}
|
||||
|
||||
// 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<string, any>
|
||||
const codexEntry: Record<string, any> = {}
|
||||
|
||||
// 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<string, any> = {}
|
||||
for (const [id, server] of Object.entries(serversToSync)) {
|
||||
const entry: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(server as Record<string, any>)) {
|
||||
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<SyncResult> {
|
||||
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: [] }
|
||||
}
|
||||
}
|
||||
+322
-308
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
let count = 0
|
||||
@@ -125,14 +255,150 @@ async function countInstalledSkills(skillsDir: string, depth = 0): Promise<numbe
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
}
|
||||
catch { /* Directory doesn't exist or can't be read */ }
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Install skill files from templates/skills/ → ~/.claude/skills/ccg/
|
||||
* Includes v1.7.73 legacy layout migration.
|
||||
*/
|
||||
async function installSkillFiles(ctx: InstallContext): Promise<void> {
|
||||
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<void> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<InstallResult> {
|
||||
// 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<void> => {
|
||||
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<UninstallR
|
||||
}
|
||||
|
||||
const commandsDir = join(installDir, 'commands', 'ccg')
|
||||
const promptsDir = join(installDir, '.ccg', 'prompts')
|
||||
const agentsDir = join(installDir, 'agents', 'ccg')
|
||||
const skillsDir = join(installDir, 'skills', 'ccg')
|
||||
const rulesDir = join(installDir, 'rules')
|
||||
@@ -468,15 +496,12 @@ export async function uninstallWorkflows(installDir: string): Promise<UninstallR
|
||||
// Remove CCG commands directory
|
||||
if (await fs.pathExists(commandsDir)) {
|
||||
try {
|
||||
// Get list for reporting
|
||||
const files = await fs.readdir(commandsDir)
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
result.removedCommands.push(file.replace('.md', ''))
|
||||
}
|
||||
}
|
||||
|
||||
// Force remove the entire directory
|
||||
await fs.remove(commandsDir)
|
||||
}
|
||||
catch (error) {
|
||||
@@ -488,13 +513,10 @@ export async function uninstallWorkflows(installDir: string): Promise<UninstallR
|
||||
// Remove CCG agents directory
|
||||
if (await fs.pathExists(agentsDir)) {
|
||||
try {
|
||||
// Get list for reporting
|
||||
const files = await fs.readdir(agentsDir)
|
||||
for (const file of files) {
|
||||
result.removedAgents.push(file.replace('.md', ''))
|
||||
}
|
||||
|
||||
// Force remove the entire directory
|
||||
await fs.remove(agentsDir)
|
||||
}
|
||||
catch (error) {
|
||||
@@ -503,10 +525,9 @@ export async function uninstallWorkflows(installDir: string): Promise<UninstallR
|
||||
}
|
||||
}
|
||||
|
||||
// Remove CCG skills directory only (skills/ccg/) — preserves user's own skills in skills/
|
||||
// Remove CCG skills directory only (skills/ccg/) — preserves user's own skills
|
||||
if (await fs.pathExists(skillsDir)) {
|
||||
try {
|
||||
// Collect skill names for reporting (SKILL.md files, excluding root)
|
||||
const collectSkillNames = async (dir: string, depth: number): Promise<string[]> => {
|
||||
const names: string[] = []
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
@@ -515,7 +536,6 @@ export async function uninstallWorkflows(installDir: string): Promise<UninstallR
|
||||
names.push(...await collectSkillNames(join(dir, entry.name), depth + 1))
|
||||
}
|
||||
else if (entry.name === 'SKILL.md' && depth > 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<UninstallR
|
||||
return names
|
||||
}
|
||||
result.removedSkills = await collectSkillNames(skillsDir, 0)
|
||||
|
||||
// Remove entire skills directory
|
||||
await fs.remove(skillsDir)
|
||||
}
|
||||
catch (error) {
|
||||
@@ -533,11 +551,10 @@ export async function uninstallWorkflows(installDir: string): Promise<UninstallR
|
||||
}
|
||||
}
|
||||
|
||||
// Remove CCG rules files (rules/ccg-skills.md, rules/ccg-grok-search.md)
|
||||
// Remove CCG rules files
|
||||
if (await fs.pathExists(rulesDir)) {
|
||||
try {
|
||||
const ccgRuleFiles = ['ccg-skills.md', 'ccg-grok-search.md']
|
||||
for (const ruleFile of ccgRuleFiles) {
|
||||
for (const ruleFile of ['ccg-skills.md', 'ccg-grok-search.md']) {
|
||||
const rulePath = join(rulesDir, ruleFile)
|
||||
if (await fs.pathExists(rulePath)) {
|
||||
await fs.remove(rulePath)
|
||||
@@ -550,8 +567,6 @@ export async function uninstallWorkflows(installDir: string): Promise<UninstallR
|
||||
}
|
||||
}
|
||||
|
||||
// Remove CCG prompts directory (in .ccg/) - handled by removing .ccg dir below
|
||||
|
||||
// Remove codeagent-wrapper binary
|
||||
if (await fs.pathExists(binDir)) {
|
||||
try {
|
||||
@@ -568,7 +583,7 @@ export async function uninstallWorkflows(installDir: string): Promise<UninstallR
|
||||
}
|
||||
}
|
||||
|
||||
// Remove .ccg config directory (Force remove)
|
||||
// Remove .ccg config directory
|
||||
if (await fs.pathExists(ccgConfigDir)) {
|
||||
try {
|
||||
await fs.remove(ccgConfigDir)
|
||||
@@ -576,7 +591,6 @@ export async function uninstallWorkflows(installDir: string): Promise<UninstallR
|
||||
}
|
||||
catch (error) {
|
||||
result.errors.push(`Failed to remove .ccg directory: ${error}`)
|
||||
// Don't mark as failure just for config, but good to know
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user