|
| 1 | +import colors from 'chalk' |
| 2 | +import fs from 'fs' |
| 3 | +import path from 'path' |
| 4 | +import inquirer from 'inquirer' |
| 5 | +import { spawnSync } from 'child_process' |
| 6 | +import { mkdirp } from 'mkdirp' |
| 7 | + |
| 8 | +import output from '../output.js' |
| 9 | +import { getTestRoot, findConfigFile } from './utils.js' |
| 10 | + |
| 11 | +const { print, success, error } = output |
| 12 | + |
| 13 | +const SERVER = 'codeceptjs' |
| 14 | +const MCP_COMMAND = ['npx', 'codeceptjs-mcp'] |
| 15 | +const SKILLS_PACKAGE = 'codeceptjs/skills' |
| 16 | + |
| 17 | +export const agents = { |
| 18 | + claude: { |
| 19 | + title: 'Claude Code', |
| 20 | + skills: 'claude-code', |
| 21 | + bin: 'claude', |
| 22 | + installUrl: 'https://code.claude.com/docs/en/setup', |
| 23 | + envFlag: '-e', |
| 24 | + }, |
| 25 | + codex: { |
| 26 | + title: 'Codex', |
| 27 | + skills: 'codex', |
| 28 | + bin: 'codex', |
| 29 | + installUrl: 'https://developers.openai.com/codex/cli', |
| 30 | + envFlag: '--env', |
| 31 | + }, |
| 32 | + cursor: { |
| 33 | + title: 'Cursor', |
| 34 | + skills: 'cursor', |
| 35 | + file: '.cursor/mcp.json', |
| 36 | + key: 'mcpServers', |
| 37 | + entry: env => ({ command: MCP_COMMAND[0], args: MCP_COMMAND.slice(1), ...(env && { env }) }), |
| 38 | + }, |
| 39 | + opencode: { |
| 40 | + title: 'OpenCode', |
| 41 | + skills: 'opencode', |
| 42 | + file: 'opencode.json', |
| 43 | + key: 'mcp', |
| 44 | + entry: env => ({ type: 'local', command: MCP_COMMAND, enabled: true, ...(env && { environment: env }) }), |
| 45 | + }, |
| 46 | +} |
| 47 | + |
| 48 | +const agentNames = Object.keys(agents) |
| 49 | + |
| 50 | +export default async function (agentName, options = {}) { |
| 51 | + if (!agentName && options.yes) return fail(`Agent is required with --yes. Use one of: ${agentNames.join(', ')}`) |
| 52 | + if (agentName && !agents[agentName]) return fail(`Unknown agent "${agentName}". Use one of: ${agentNames.join(', ')}`) |
| 53 | + |
| 54 | + if (!agentName) { |
| 55 | + const answer = await inquirer.prompt([ |
| 56 | + { |
| 57 | + name: 'agent', |
| 58 | + type: 'list', |
| 59 | + message: 'Which coding agent do you use?', |
| 60 | + choices: agentNames, |
| 61 | + }, |
| 62 | + ]) |
| 63 | + agentName = answer.agent |
| 64 | + } |
| 65 | + return setupAgent(agentName, options) |
| 66 | +} |
| 67 | + |
| 68 | +export async function setupAgent(agentName, options = {}) { |
| 69 | + const agent = agents[agentName] |
| 70 | + if (!agent) throw new Error(`Unknown agent "${agentName}". Use one of: ${agentNames.join(', ')}`) |
| 71 | + |
| 72 | + const run = options.runner || spawnCommand |
| 73 | + const cwd = process.cwd() |
| 74 | + const env = configEnv(options.config, cwd) |
| 75 | + |
| 76 | + print() |
| 77 | + print(`Setting up CodeceptJS for ${colors.bold(agent.title)}`) |
| 78 | + print() |
| 79 | + |
| 80 | + let ready = agent.bin ? registerWithCli(agent, env, run) : writeMcpFile(path.join(cwd, agent.file), agent, env) |
| 81 | + |
| 82 | + const skillsArgs = ['skills', 'add', SKILLS_PACKAGE, '-a', agent.skills, '-y'] |
| 83 | + print(`Installing CodeceptJS skills: ${colors.green(['npx', ...skillsArgs].join(' '))}`) |
| 84 | + if (!succeeded(run('npx', skillsArgs))) { |
| 85 | + ready = false |
| 86 | + error('Installing skills failed. Run it manually:') |
| 87 | + print(` npx ${skillsArgs.join(' ')}`) |
| 88 | + } |
| 89 | + |
| 90 | + print() |
| 91 | + if (ready) { |
| 92 | + success(`Start ${agent.title} in this project. CodeceptJS skills and MCP are ready.`) |
| 93 | + } else { |
| 94 | + print(`Run the commands above, then start ${agent.title} in this project.`) |
| 95 | + } |
| 96 | + print() |
| 97 | + return ready |
| 98 | +} |
| 99 | + |
| 100 | +function registerWithCli(agent, env, run) { |
| 101 | + const addArgs = ['mcp', 'add', SERVER] |
| 102 | + if (env) Object.entries(env).forEach(([key, value]) => addArgs.push(agent.envFlag, `${key}=${value}`)) |
| 103 | + addArgs.push('--', ...MCP_COMMAND) |
| 104 | + const manual = ` ${agent.bin} ${addArgs.join(' ')}` |
| 105 | + |
| 106 | + if (!succeeded(run(agent.bin, ['--version'], { quiet: true }))) { |
| 107 | + error(`${agent.title} CLI (${agent.bin}) was not found. Install it: ${agent.installUrl}`) |
| 108 | + print('Then register the MCP server:') |
| 109 | + print(manual) |
| 110 | + return false |
| 111 | + } |
| 112 | + |
| 113 | + if (succeeded(run(agent.bin, ['mcp', 'get', SERVER], { quiet: true }))) { |
| 114 | + print(`MCP server "${SERVER}" is already registered in ${agent.title}`) |
| 115 | + return true |
| 116 | + } |
| 117 | + |
| 118 | + print(`Registering MCP server: ${colors.green(`${agent.bin} ${addArgs.join(' ')}`)}`) |
| 119 | + if (succeeded(run(agent.bin, addArgs))) return true |
| 120 | + |
| 121 | + error('Registering MCP server failed. Run it manually:') |
| 122 | + print(manual) |
| 123 | + return false |
| 124 | +} |
| 125 | + |
| 126 | +function writeMcpFile(file, agent, env) { |
| 127 | + const entry = agent.entry(env) |
| 128 | + const relative = path.relative(process.cwd(), file) |
| 129 | + let data = {} |
| 130 | + |
| 131 | + if (fs.existsSync(file)) { |
| 132 | + try { |
| 133 | + data = JSON.parse(fs.readFileSync(file, 'utf8')) |
| 134 | + } catch (err) { |
| 135 | + data = null |
| 136 | + } |
| 137 | + if (!isObject(data) || (data[agent.key] !== undefined && !isObject(data[agent.key]))) { |
| 138 | + error(`Could not parse ${relative}, leaving it untouched. Add this to "${agent.key}" manually:`) |
| 139 | + print(JSON.stringify({ [SERVER]: entry }, null, 2)) |
| 140 | + return false |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + if (data[agent.key] && JSON.stringify(data[agent.key][SERVER]) === JSON.stringify(entry)) { |
| 145 | + print(`MCP server "${SERVER}" is already configured in ${relative}`) |
| 146 | + return true |
| 147 | + } |
| 148 | + |
| 149 | + data[agent.key] = { ...(data[agent.key] || {}), [SERVER]: entry } |
| 150 | + mkdirp.sync(path.dirname(file)) |
| 151 | + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`) |
| 152 | + print(`MCP server "${SERVER}" added to ${relative}`) |
| 153 | + return true |
| 154 | +} |
| 155 | + |
| 156 | +function configEnv(config, cwd) { |
| 157 | + let configFile = config ? path.resolve(cwd, config) : findConfigFile(getTestRoot()) |
| 158 | + if (configFile && fs.existsSync(configFile) && fs.lstatSync(configFile).isDirectory()) configFile = findConfigFile(configFile) |
| 159 | + if (!configFile || configFile === path.join(cwd, 'codecept.conf.js')) return null |
| 160 | + const relative = path.relative(cwd, configFile) |
| 161 | + return { CODECEPTJS_CONFIG: relative.startsWith('..') ? configFile : `./${relative.split(path.sep).join('/')}` } |
| 162 | +} |
| 163 | + |
| 164 | +function fail(message) { |
| 165 | + error(message) |
| 166 | + process.exitCode = 1 |
| 167 | + return false |
| 168 | +} |
| 169 | + |
| 170 | +function isObject(value) { |
| 171 | + return !!value && typeof value === 'object' && !Array.isArray(value) |
| 172 | +} |
| 173 | + |
| 174 | +function succeeded(result) { |
| 175 | + return !!result && !result.error && result.status === 0 |
| 176 | +} |
| 177 | + |
| 178 | +function spawnCommand(command, args, { quiet } = {}) { |
| 179 | + return spawnSync(command, args, { stdio: quiet ? 'ignore' : 'inherit', shell: process.platform === 'win32' }) |
| 180 | +} |
0 commit comments