From a64380455eb22fd2a65fde15b22bae98ac6e2dcc Mon Sep 17 00:00:00 2001 From: DavertMik Date: Tue, 6 Oct 2026 01:42:07 +0300 Subject: [PATCH 1/3] feat: add init:agent command to set up MCP and skills for coding agents `npx codeceptjs init:agent [claude|codex|cursor|opencode]` registers the CodeceptJS MCP server and installs CodeceptJS skills for the chosen agent. Claude and Codex use their own `mcp add` commands; Cursor and OpenCode get their project MCP file merged. `init` gains `--agent` and a final prompt. Co-Authored-By: Claude Opus 5.5 --- bin/codecept.js | 9 ++ docs/agents.md | 10 +- docs/commands.md | 10 ++ docs/mcp.md | 8 +- lib/command/init.js | 27 ++++ lib/command/initAgent.js | 180 +++++++++++++++++++++++++++ test/unit/command/initAgent_test.js | 183 ++++++++++++++++++++++++++++ 7 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 lib/command/initAgent.js create mode 100644 test/unit/command/initAgent_test.js diff --git a/bin/codecept.js b/bin/codecept.js index e270e2920..fd6fec231 100755 --- a/bin/codecept.js +++ b/bin/codecept.js @@ -78,8 +78,17 @@ program .command('init [path]') .description('Creates dummy config in current dir or [path]') .option('-y, --yes', 'skip prompts and use defaults (Playwright + chromium, BASE_URL env for url)') + .option('--agent ', 'set up an AI coding agent: claude, codex, cursor, opencode') .action(commandHandler('../lib/command/init.js')) +program + .command('init:agent [agent]') + .alias('init-agent') + .description('Set up CodeceptJS MCP server and skills for a coding agent: claude, codex, cursor, opencode') + .option(commandFlags.config.flag, commandFlags.config.description) + .option('-y, --yes', 'skip prompts, requires [agent]') + .action(commandHandler('../lib/command/initAgent.js')) + program .command('check') .option(commandFlags.config.flag, commandFlags.config.description) diff --git a/docs/agents.md b/docs/agents.md index ea7153c88..621c516ea 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -25,7 +25,15 @@ CodeceptJS is token-efficient: it stores HTML, ARIA, logs, and HTTP request data ## Essential Setup -Two things make agent testing work: the **skills** that teach the agent CodeceptJS, and the **MCP server** that lets it drive the browser. Set both up once, from your project directory. +Two things make agent testing work: the **skills** that teach the agent CodeceptJS, and the **MCP server** that lets it drive the browser. Set both up once, from your project directory: + +```bash +npx codeceptjs init:agent claude +``` + +Use `codex`, `cursor`, or `opencode` instead of `claude` for other agents, or omit the name to pick from a list. `npx codeceptjs init` offers the same step at the end. + +### Manual setup Install the skills: diff --git a/docs/commands.md b/docs/commands.md index dff6a23b6..fc71f9ff7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -262,6 +262,16 @@ Or in provided path npx codecept init test ``` +## Init Agent + +Registers the CodeceptJS MCP server and installs CodeceptJS skills for a coding agent (`claude`, `codex`, `cursor`, `opencode`): + +```sh +npx codeceptjs init:agent claude +``` + +Omit the agent to pick it from a list. Use `-c` to point the MCP server to a config other than `./codecept.conf.js`. See [Agentic Testing](/agents). + ## Migrate Migrate your current `codecept.json` to `codecept.conf.js` diff --git a/docs/mcp.md b/docs/mcp.md index bb6585ebe..5469d1f1f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -27,7 +27,13 @@ Two ways to launch the server: ## Configuration -Set up the MCP server in your client (Claude Desktop, Cursor, Continue, etc.): +For Claude Code, Codex, Cursor, or OpenCode, one command registers the server and installs the CodeceptJS skills: + +```bash +npx codeceptjs init:agent claude +``` + +To set up the MCP server in another client (Claude Desktop, Continue, etc.), add it to the client config: ### Basic diff --git a/lib/command/init.js b/lib/command/init.js index 8aa310ed7..86d42d908 100644 --- a/lib/command/init.js +++ b/lib/command/init.js @@ -12,6 +12,7 @@ import { fileExists, beautify, installedLocally } from '../utils.js' import { getTestRoot } from './utils.js' import generateDefinitions from './definitions.js' import { test as generateTest } from './generate.js' +import { agents, setupAgent } from './initAgent.js' const isLocal = installedLocally() const defaultConfig = { @@ -72,6 +73,11 @@ export default async function (initPath, options = {}) { const testsPath = getTestRoot(initPath) const skipPrompts = !!options.yes + if (options.agent && !agents[options.agent]) { + error(`Unknown agent "${options.agent}". Use one of: ${Object.keys(agents).join(', ')}`) + return + } + print() print(` Welcome to ${colors.magenta.bold('CodeceptJS')} initialization tool`) print(' It will prepare and configure a test environment for you') @@ -318,6 +324,27 @@ export default async function (initPath, options = {}) { print(generateDefinitionsManually) } + let agentName = options.agent + if (!agentName && !skipPrompts) { + const answer = await inquirer.prompt([ + { + name: 'agent', + type: 'list', + message: 'Set up an AI coding agent?', + choices: [...Object.keys(agents), 'none'], + default: 'none', + }, + ]) + agentName = answer?.agent + } + + if (agentName && agentName !== 'none') { + await setupAgent(agentName, { config: isTypeScript ? typeScriptconfigFile : configFile }) + } else { + print() + print(`Using an AI agent? Run: ${colors.green('npx codeceptjs init:agent')}`) + } + print('') success(' Almost ready... Next step:') diff --git a/lib/command/initAgent.js b/lib/command/initAgent.js new file mode 100644 index 000000000..c1e9ea879 --- /dev/null +++ b/lib/command/initAgent.js @@ -0,0 +1,180 @@ +import colors from 'chalk' +import fs from 'fs' +import path from 'path' +import inquirer from 'inquirer' +import { spawnSync } from 'child_process' +import { mkdirp } from 'mkdirp' + +import output from '../output.js' +import { getTestRoot, findConfigFile } from './utils.js' + +const { print, success, error } = output + +const SERVER = 'codeceptjs' +const MCP_COMMAND = ['npx', 'codeceptjs-mcp'] +const SKILLS_PACKAGE = 'codeceptjs/skills' + +export const agents = { + claude: { + title: 'Claude Code', + skills: 'claude-code', + bin: 'claude', + installUrl: 'https://code.claude.com/docs/en/setup', + envFlag: '-e', + }, + codex: { + title: 'Codex', + skills: 'codex', + bin: 'codex', + installUrl: 'https://developers.openai.com/codex/cli', + envFlag: '--env', + }, + cursor: { + title: 'Cursor', + skills: 'cursor', + file: '.cursor/mcp.json', + key: 'mcpServers', + entry: env => ({ command: MCP_COMMAND[0], args: MCP_COMMAND.slice(1), ...(env && { env }) }), + }, + opencode: { + title: 'OpenCode', + skills: 'opencode', + file: 'opencode.json', + key: 'mcp', + entry: env => ({ type: 'local', command: MCP_COMMAND, enabled: true, ...(env && { environment: env }) }), + }, +} + +const agentNames = Object.keys(agents) + +export default async function (agentName, options = {}) { + if (!agentName && options.yes) return fail(`Agent is required with --yes. Use one of: ${agentNames.join(', ')}`) + if (agentName && !agents[agentName]) return fail(`Unknown agent "${agentName}". Use one of: ${agentNames.join(', ')}`) + + if (!agentName) { + const answer = await inquirer.prompt([ + { + name: 'agent', + type: 'list', + message: 'Which coding agent do you use?', + choices: agentNames, + }, + ]) + agentName = answer.agent + } + return setupAgent(agentName, options) +} + +export async function setupAgent(agentName, options = {}) { + const agent = agents[agentName] + if (!agent) throw new Error(`Unknown agent "${agentName}". Use one of: ${agentNames.join(', ')}`) + + const run = options.runner || spawnCommand + const cwd = process.cwd() + const env = configEnv(options.config, cwd) + + print() + print(`Setting up CodeceptJS for ${colors.bold(agent.title)}`) + print() + + let ready = agent.bin ? registerWithCli(agent, env, run) : writeMcpFile(path.join(cwd, agent.file), agent, env) + + const skillsArgs = ['skills', 'add', SKILLS_PACKAGE, '-a', agent.skills, '-y'] + print(`Installing CodeceptJS skills: ${colors.green(['npx', ...skillsArgs].join(' '))}`) + if (!succeeded(run('npx', skillsArgs))) { + ready = false + error('Installing skills failed. Run it manually:') + print(` npx ${skillsArgs.join(' ')}`) + } + + print() + if (ready) { + success(`Start ${agent.title} in this project. CodeceptJS skills and MCP are ready.`) + } else { + print(`Run the commands above, then start ${agent.title} in this project.`) + } + print() + return ready +} + +function registerWithCli(agent, env, run) { + const addArgs = ['mcp', 'add', SERVER] + if (env) Object.entries(env).forEach(([key, value]) => addArgs.push(agent.envFlag, `${key}=${value}`)) + addArgs.push('--', ...MCP_COMMAND) + const manual = ` ${agent.bin} ${addArgs.join(' ')}` + + if (!succeeded(run(agent.bin, ['--version'], { quiet: true }))) { + error(`${agent.title} CLI (${agent.bin}) was not found. Install it: ${agent.installUrl}`) + print('Then register the MCP server:') + print(manual) + return false + } + + if (succeeded(run(agent.bin, ['mcp', 'get', SERVER], { quiet: true }))) { + print(`MCP server "${SERVER}" is already registered in ${agent.title}`) + return true + } + + print(`Registering MCP server: ${colors.green(`${agent.bin} ${addArgs.join(' ')}`)}`) + if (succeeded(run(agent.bin, addArgs))) return true + + error('Registering MCP server failed. Run it manually:') + print(manual) + return false +} + +function writeMcpFile(file, agent, env) { + const entry = agent.entry(env) + const relative = path.relative(process.cwd(), file) + let data = {} + + if (fs.existsSync(file)) { + try { + data = JSON.parse(fs.readFileSync(file, 'utf8')) + } catch (err) { + data = null + } + if (!isObject(data) || (data[agent.key] !== undefined && !isObject(data[agent.key]))) { + error(`Could not parse ${relative}, leaving it untouched. Add this to "${agent.key}" manually:`) + print(JSON.stringify({ [SERVER]: entry }, null, 2)) + return false + } + } + + if (data[agent.key] && JSON.stringify(data[agent.key][SERVER]) === JSON.stringify(entry)) { + print(`MCP server "${SERVER}" is already configured in ${relative}`) + return true + } + + data[agent.key] = { ...(data[agent.key] || {}), [SERVER]: entry } + mkdirp.sync(path.dirname(file)) + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`) + print(`MCP server "${SERVER}" added to ${relative}`) + return true +} + +function configEnv(config, cwd) { + let configFile = config ? path.resolve(cwd, config) : findConfigFile(getTestRoot()) + if (configFile && fs.existsSync(configFile) && fs.lstatSync(configFile).isDirectory()) configFile = findConfigFile(configFile) + if (!configFile || configFile === path.join(cwd, 'codecept.conf.js')) return null + const relative = path.relative(cwd, configFile) + return { CODECEPTJS_CONFIG: relative.startsWith('..') ? configFile : `./${relative.split(path.sep).join('/')}` } +} + +function fail(message) { + error(message) + process.exitCode = 1 + return false +} + +function isObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function succeeded(result) { + return !!result && !result.error && result.status === 0 +} + +function spawnCommand(command, args, { quiet } = {}) { + return spawnSync(command, args, { stdio: quiet ? 'ignore' : 'inherit', shell: process.platform === 'win32' }) +} diff --git a/test/unit/command/initAgent_test.js b/test/unit/command/initAgent_test.js new file mode 100644 index 000000000..e831cab28 --- /dev/null +++ b/test/unit/command/initAgent_test.js @@ -0,0 +1,183 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import initAgent from '../../../lib/command/initAgent.js' + +describe('init:agent command', () => { + let tempDir + let originalCwd + let calls + + const runner = + (handler = () => 0) => + (command, args, opts = {}) => { + calls.push({ command, args, quiet: !!opts.quiet }) + return { status: handler(command, args) } + } + + const failing = (command, args) => (args[0] === 'mcp' && args[1] === 'get' ? 1 : 0) + + const listFiles = () => fs.readdirSync(tempDir, { recursive: true }) + const readJson = file => JSON.parse(fs.readFileSync(path.join(tempDir, file), 'utf8')) + + beforeEach(() => { + originalCwd = process.cwd() + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codecept-init-agent-'))) + process.chdir(tempDir) + fs.writeFileSync(path.join(tempDir, 'codecept.conf.js'), 'export const config = {}') + calls = [] + }) + + afterEach(() => { + process.chdir(originalCwd) + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + describe('claude', () => { + it('registers MCP and installs skills without writing files', async () => { + const ready = await initAgent('claude', { yes: true, runner: runner(failing) }) + + expect(ready).to.be.true + expect(calls.filter(c => !c.quiet).map(c => [c.command, ...c.args])).to.deep.equal([ + ['claude', 'mcp', 'add', 'codeceptjs', '--', 'npx', 'codeceptjs-mcp'], + ['npx', 'skills', 'add', 'codeceptjs/skills', '-a', 'claude-code', '-y'], + ]) + expect(listFiles()).to.deep.equal(['codecept.conf.js']) + }) + + it('skips mcp add when the server is already registered', async () => { + await initAgent('claude', { yes: true, runner: runner() }) + + expect(calls.map(c => c.args.slice(0, 2).join(' '))).to.include('mcp get') + expect(calls.some(c => c.args[1] === 'add' && c.command === 'claude')).to.be.false + expect(calls.some(c => c.command === 'npx')).to.be.true + }) + + it('passes CODECEPTJS_CONFIG for a non-default config', async () => { + fs.mkdirSync(path.join(tempDir, 'e2e')) + fs.writeFileSync(path.join(tempDir, 'e2e/codecept.conf.ts'), 'export const config = {}') + + await initAgent('claude', { yes: true, config: 'e2e/codecept.conf.ts', runner: runner(failing) }) + + const add = calls.find(c => c.command === 'claude' && c.args[1] === 'add') + expect(add.args).to.deep.equal(['mcp', 'add', 'codeceptjs', '-e', 'CODECEPTJS_CONFIG=./e2e/codecept.conf.ts', '--', 'npx', 'codeceptjs-mcp']) + }) + + it('continues when the claude binary is missing', async () => { + const ready = await initAgent('claude', { yes: true, runner: runner(command => (command === 'claude' ? 127 : 0)) }) + + expect(ready).to.be.false + expect(calls.some(c => c.args[1] === 'add' && c.command === 'claude')).to.be.false + expect(calls.some(c => c.command === 'npx')).to.be.true + }) + + it('continues when skills install fails', async () => { + const ready = await initAgent('claude', { yes: true, runner: runner((command, args) => (command === 'npx' ? 1 : failing(command, args))) }) + + expect(ready).to.be.false + expect(calls.some(c => c.command === 'claude' && c.args[1] === 'add')).to.be.true + }) + }) + + describe('codex', () => { + it('registers MCP with codex and installs skills', async () => { + await initAgent('codex', { yes: true, runner: runner(failing) }) + + expect(calls.filter(c => !c.quiet).map(c => [c.command, ...c.args])).to.deep.equal([ + ['codex', 'mcp', 'add', 'codeceptjs', '--', 'npx', 'codeceptjs-mcp'], + ['npx', 'skills', 'add', 'codeceptjs/skills', '-a', 'codex', '-y'], + ]) + expect(listFiles()).to.deep.equal(['codecept.conf.js']) + }) + }) + + describe('cursor', () => { + it('writes .cursor/mcp.json and installs skills', async () => { + await initAgent('cursor', { yes: true, runner: runner() }) + + expect(readJson('.cursor/mcp.json')).to.deep.equal({ + mcpServers: { codeceptjs: { command: 'npx', args: ['codeceptjs-mcp'] } }, + }) + expect(calls.map(c => [c.command, ...c.args])).to.deep.equal([['npx', 'skills', 'add', 'codeceptjs/skills', '-a', 'cursor', '-y']]) + }) + + it('preserves other servers and keys', async () => { + fs.mkdirSync(path.join(tempDir, '.cursor')) + fs.writeFileSync(path.join(tempDir, '.cursor/mcp.json'), JSON.stringify({ other: true, mcpServers: { github: { command: 'gh' } } })) + + await initAgent('cursor', { yes: true, runner: runner() }) + + expect(readJson('.cursor/mcp.json')).to.deep.equal({ + other: true, + mcpServers: { github: { command: 'gh' }, codeceptjs: { command: 'npx', args: ['codeceptjs-mcp'] } }, + }) + }) + + it('produces the same file on a second run', async () => { + await initAgent('cursor', { yes: true, runner: runner() }) + const first = fs.readFileSync(path.join(tempDir, '.cursor/mcp.json'), 'utf8') + await initAgent('cursor', { yes: true, runner: runner() }) + expect(fs.readFileSync(path.join(tempDir, '.cursor/mcp.json'), 'utf8')).to.equal(first) + }) + + it('leaves unparseable JSON untouched', async () => { + const jsonc = '{\n // comment\n "mcpServers": {}\n}\n' + fs.mkdirSync(path.join(tempDir, '.cursor')) + fs.writeFileSync(path.join(tempDir, '.cursor/mcp.json'), jsonc) + + const ready = await initAgent('cursor', { yes: true, runner: runner() }) + + expect(ready).to.be.false + expect(fs.readFileSync(path.join(tempDir, '.cursor/mcp.json'), 'utf8')).to.equal(jsonc) + expect(calls.some(c => c.command === 'npx')).to.be.true + }) + }) + + describe('opencode', () => { + it('writes opencode.json preserving other keys', async () => { + fs.writeFileSync(path.join(tempDir, 'opencode.json'), JSON.stringify({ $schema: 'https://opencode.ai/config.json', mcp: { other: { type: 'remote', url: 'http://x' } } })) + + await initAgent('opencode', { yes: true, runner: runner() }) + + expect(readJson('opencode.json')).to.deep.equal({ + $schema: 'https://opencode.ai/config.json', + mcp: { + other: { type: 'remote', url: 'http://x' }, + codeceptjs: { type: 'local', command: ['npx', 'codeceptjs-mcp'], enabled: true }, + }, + }) + expect(calls.map(c => [c.command, ...c.args])).to.deep.equal([['npx', 'skills', 'add', 'codeceptjs/skills', '-a', 'opencode', '-y']]) + }) + + it('produces the same file on a second run', async () => { + await initAgent('opencode', { yes: true, runner: runner() }) + const first = fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8') + await initAgent('opencode', { yes: true, runner: runner() }) + expect(fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8')).to.equal(first) + }) + }) + + describe('arguments', () => { + afterEach(() => { + process.exitCode = 0 + }) + + it('requires an agent with --yes', async () => { + const ready = await initAgent(undefined, { yes: true, runner: runner() }) + + expect(ready).to.be.false + expect(process.exitCode).to.equal(1) + expect(calls).to.be.empty + expect(listFiles()).to.deep.equal(['codecept.conf.js']) + }) + + it('rejects unknown agents', async () => { + const ready = await initAgent('vim', { yes: true, runner: runner() }) + + expect(ready).to.be.false + expect(process.exitCode).to.equal(1) + expect(calls).to.be.empty + }) + }) +}) From 89e3cc512ebe9cfdd4d2cdc284c52595ea342cc1 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Tue, 6 Oct 2026 01:43:48 +0300 Subject: [PATCH 2/3] fix: ignore valueless --config in init:agent Co-Authored-By: Claude Opus 5.5 --- lib/command/initAgent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/command/initAgent.js b/lib/command/initAgent.js index c1e9ea879..2b75f8218 100644 --- a/lib/command/initAgent.js +++ b/lib/command/initAgent.js @@ -154,7 +154,7 @@ function writeMcpFile(file, agent, env) { } function configEnv(config, cwd) { - let configFile = config ? path.resolve(cwd, config) : findConfigFile(getTestRoot()) + let configFile = typeof config === 'string' ? path.resolve(cwd, config) : findConfigFile(getTestRoot()) if (configFile && fs.existsSync(configFile) && fs.lstatSync(configFile).isDirectory()) configFile = findConfigFile(configFile) if (!configFile || configFile === path.join(cwd, 'codecept.conf.js')) return null const relative = path.relative(cwd, configFile) From 51097a92c958af59e5ffeac0484044b8667f7a6e Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 7 Oct 2026 02:53:48 +0300 Subject: [PATCH 3/3] refactor: simplify init:agent One agents table and execSync with the agents' own commands. Drop the runner injection, CLI detection, mcp get check and CODECEPTJS_CONFIG handling. Tests use fake binaries on PATH. Co-Authored-By: Claude Opus 5.5 --- bin/codecept.js | 1 - docs/commands.md | 2 +- lib/command/init.js | 9 +- lib/command/initAgent.js | 174 +++++------------------- test/unit/command/initAgent_test.js | 203 ++++++++++------------------ 5 files changed, 111 insertions(+), 278 deletions(-) diff --git a/bin/codecept.js b/bin/codecept.js index fd6fec231..b176da9a8 100755 --- a/bin/codecept.js +++ b/bin/codecept.js @@ -85,7 +85,6 @@ program .command('init:agent [agent]') .alias('init-agent') .description('Set up CodeceptJS MCP server and skills for a coding agent: claude, codex, cursor, opencode') - .option(commandFlags.config.flag, commandFlags.config.description) .option('-y, --yes', 'skip prompts, requires [agent]') .action(commandHandler('../lib/command/initAgent.js')) diff --git a/docs/commands.md b/docs/commands.md index fc71f9ff7..f27c5014b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -270,7 +270,7 @@ Registers the CodeceptJS MCP server and installs CodeceptJS skills for a coding npx codeceptjs init:agent claude ``` -Omit the agent to pick it from a list. Use `-c` to point the MCP server to a config other than `./codecept.conf.js`. See [Agentic Testing](/agents). +Omit the agent to pick it from a list. See [Agentic Testing](/agents). ## Migrate diff --git a/lib/command/init.js b/lib/command/init.js index 86d42d908..7a18e6def 100644 --- a/lib/command/init.js +++ b/lib/command/init.js @@ -12,7 +12,7 @@ import { fileExists, beautify, installedLocally } from '../utils.js' import { getTestRoot } from './utils.js' import generateDefinitions from './definitions.js' import { test as generateTest } from './generate.js' -import { agents, setupAgent } from './initAgent.js' +import initAgent, { agents } from './initAgent.js' const isLocal = installedLocally() const defaultConfig = { @@ -73,11 +73,6 @@ export default async function (initPath, options = {}) { const testsPath = getTestRoot(initPath) const skipPrompts = !!options.yes - if (options.agent && !agents[options.agent]) { - error(`Unknown agent "${options.agent}". Use one of: ${Object.keys(agents).join(', ')}`) - return - } - print() print(` Welcome to ${colors.magenta.bold('CodeceptJS')} initialization tool`) print(' It will prepare and configure a test environment for you') @@ -339,7 +334,7 @@ export default async function (initPath, options = {}) { } if (agentName && agentName !== 'none') { - await setupAgent(agentName, { config: isTypeScript ? typeScriptconfigFile : configFile }) + await initAgent(agentName, { yes: true }) } else { print() print(`Using an AI agent? Run: ${colors.green('npx codeceptjs init:agent')}`) diff --git a/lib/command/initAgent.js b/lib/command/initAgent.js index 2b75f8218..f80a71a3f 100644 --- a/lib/command/initAgent.js +++ b/lib/command/initAgent.js @@ -1,180 +1,78 @@ -import colors from 'chalk' import fs from 'fs' import path from 'path' import inquirer from 'inquirer' -import { spawnSync } from 'child_process' -import { mkdirp } from 'mkdirp' +import { execSync } from 'child_process' import output from '../output.js' -import { getTestRoot, findConfigFile } from './utils.js' const { print, success, error } = output -const SERVER = 'codeceptjs' -const MCP_COMMAND = ['npx', 'codeceptjs-mcp'] -const SKILLS_PACKAGE = 'codeceptjs/skills' - export const agents = { claude: { - title: 'Claude Code', skills: 'claude-code', - bin: 'claude', - installUrl: 'https://code.claude.com/docs/en/setup', - envFlag: '-e', + mcp: 'claude mcp add codeceptjs -- npx codeceptjs-mcp', }, codex: { - title: 'Codex', skills: 'codex', - bin: 'codex', - installUrl: 'https://developers.openai.com/codex/cli', - envFlag: '--env', + mcp: 'codex mcp add codeceptjs -- npx codeceptjs-mcp', }, cursor: { - title: 'Cursor', skills: 'cursor', file: '.cursor/mcp.json', key: 'mcpServers', - entry: env => ({ command: MCP_COMMAND[0], args: MCP_COMMAND.slice(1), ...(env && { env }) }), + server: { command: 'npx', args: ['codeceptjs-mcp'] }, }, opencode: { - title: 'OpenCode', skills: 'opencode', file: 'opencode.json', key: 'mcp', - entry: env => ({ type: 'local', command: MCP_COMMAND, enabled: true, ...(env && { environment: env }) }), + server: { type: 'local', command: ['npx', 'codeceptjs-mcp'], enabled: true }, }, } -const agentNames = Object.keys(agents) - export default async function (agentName, options = {}) { - if (!agentName && options.yes) return fail(`Agent is required with --yes. Use one of: ${agentNames.join(', ')}`) - if (agentName && !agents[agentName]) return fail(`Unknown agent "${agentName}". Use one of: ${agentNames.join(', ')}`) - - if (!agentName) { - const answer = await inquirer.prompt([ - { - name: 'agent', - type: 'list', - message: 'Which coding agent do you use?', - choices: agentNames, - }, - ]) + if (!agentName && !options.yes) { + const answer = await inquirer.prompt([{ name: 'agent', type: 'list', message: 'Which coding agent do you use?', choices: Object.keys(agents) }]) agentName = answer.agent } - return setupAgent(agentName, options) -} -export async function setupAgent(agentName, options = {}) { const agent = agents[agentName] - if (!agent) throw new Error(`Unknown agent "${agentName}". Use one of: ${agentNames.join(', ')}`) - - const run = options.runner || spawnCommand - const cwd = process.cwd() - const env = configEnv(options.config, cwd) - - print() - print(`Setting up CodeceptJS for ${colors.bold(agent.title)}`) - print() - - let ready = agent.bin ? registerWithCli(agent, env, run) : writeMcpFile(path.join(cwd, agent.file), agent, env) - - const skillsArgs = ['skills', 'add', SKILLS_PACKAGE, '-a', agent.skills, '-y'] - print(`Installing CodeceptJS skills: ${colors.green(['npx', ...skillsArgs].join(' '))}`) - if (!succeeded(run('npx', skillsArgs))) { - ready = false - error('Installing skills failed. Run it manually:') - print(` npx ${skillsArgs.join(' ')}`) - } - - print() - if (ready) { - success(`Start ${agent.title} in this project. CodeceptJS skills and MCP are ready.`) - } else { - print(`Run the commands above, then start ${agent.title} in this project.`) - } - print() - return ready -} - -function registerWithCli(agent, env, run) { - const addArgs = ['mcp', 'add', SERVER] - if (env) Object.entries(env).forEach(([key, value]) => addArgs.push(agent.envFlag, `${key}=${value}`)) - addArgs.push('--', ...MCP_COMMAND) - const manual = ` ${agent.bin} ${addArgs.join(' ')}` - - if (!succeeded(run(agent.bin, ['--version'], { quiet: true }))) { - error(`${agent.title} CLI (${agent.bin}) was not found. Install it: ${agent.installUrl}`) - print('Then register the MCP server:') - print(manual) - return false - } - - if (succeeded(run(agent.bin, ['mcp', 'get', SERVER], { quiet: true }))) { - print(`MCP server "${SERVER}" is already registered in ${agent.title}`) - return true + if (!agent) { + error(`Agent must be one of: ${Object.keys(agents).join(', ')}`) + process.exitCode = 1 + return } - print(`Registering MCP server: ${colors.green(`${agent.bin} ${addArgs.join(' ')}`)}`) - if (succeeded(run(agent.bin, addArgs))) return true - - error('Registering MCP server failed. Run it manually:') - print(manual) - return false -} - -function writeMcpFile(file, agent, env) { - const entry = agent.entry(env) - const relative = path.relative(process.cwd(), file) - let data = {} - - if (fs.existsSync(file)) { - try { - data = JSON.parse(fs.readFileSync(file, 'utf8')) - } catch (err) { - data = null - } - if (!isObject(data) || (data[agent.key] !== undefined && !isObject(data[agent.key]))) { - error(`Could not parse ${relative}, leaving it untouched. Add this to "${agent.key}" manually:`) - print(JSON.stringify({ [SERVER]: entry }, null, 2)) - return false + let ready = true + try { + if (agent.mcp) { + execSync(agent.mcp, { stdio: 'inherit' }) + } else { + addServerToFile(agent) + print(`Added codeceptjs MCP server to ${agent.file}`) } + } catch (err) { + ready = false + error(`Could not register the codeceptjs MCP server: ${err.message}`) + if (agent.mcp) print(`Run: ${agent.mcp}`) + if (agent.file) print(`Add to "${agent.key}" in ${agent.file}: ${JSON.stringify({ codeceptjs: agent.server })}`) } - if (data[agent.key] && JSON.stringify(data[agent.key][SERVER]) === JSON.stringify(entry)) { - print(`MCP server "${SERVER}" is already configured in ${relative}`) - return true + const skills = `npx skills add codeceptjs/skills -a ${agent.skills} -y` + try { + execSync(skills, { stdio: 'inherit' }) + } catch (err) { + ready = false + error(`Could not install skills. Run: ${skills}`) } - data[agent.key] = { ...(data[agent.key] || {}), [SERVER]: entry } - mkdirp.sync(path.dirname(file)) - fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`) - print(`MCP server "${SERVER}" added to ${relative}`) - return true -} - -function configEnv(config, cwd) { - let configFile = typeof config === 'string' ? path.resolve(cwd, config) : findConfigFile(getTestRoot()) - if (configFile && fs.existsSync(configFile) && fs.lstatSync(configFile).isDirectory()) configFile = findConfigFile(configFile) - if (!configFile || configFile === path.join(cwd, 'codecept.conf.js')) return null - const relative = path.relative(cwd, configFile) - return { CODECEPTJS_CONFIG: relative.startsWith('..') ? configFile : `./${relative.split(path.sep).join('/')}` } -} - -function fail(message) { - error(message) - process.exitCode = 1 - return false -} - -function isObject(value) { - return !!value && typeof value === 'object' && !Array.isArray(value) -} - -function succeeded(result) { - return !!result && !result.error && result.status === 0 + if (ready) success(`Start ${agentName} in this project. CodeceptJS skills and MCP are ready.`) } -function spawnCommand(command, args, { quiet } = {}) { - return spawnSync(command, args, { stdio: quiet ? 'ignore' : 'inherit', shell: process.platform === 'win32' }) +function addServerToFile(agent) { + let config = {} + if (fs.existsSync(agent.file)) config = JSON.parse(fs.readFileSync(agent.file, 'utf8')) + config[agent.key] = { ...config[agent.key], codeceptjs: agent.server } + fs.mkdirSync(path.dirname(agent.file), { recursive: true }) + fs.writeFileSync(agent.file, `${JSON.stringify(config, null, 2)}\n`) } diff --git a/test/unit/command/initAgent_test.js b/test/unit/command/initAgent_test.js index e831cab28..e619f55cd 100644 --- a/test/unit/command/initAgent_test.js +++ b/test/unit/command/initAgent_test.js @@ -6,178 +6,119 @@ import initAgent from '../../../lib/command/initAgent.js' describe('init:agent command', () => { let tempDir + let binDir + let logFile let originalCwd - let calls + let originalPath - const runner = - (handler = () => 0) => - (command, args, opts = {}) => { - calls.push({ command, args, quiet: !!opts.quiet }) - return { status: handler(command, args) } - } + const fakeBin = (name, exitCode = 0) => { + const file = path.join(binDir, name) + fs.writeFileSync(file, `#!/bin/sh\necho "${name} $*" >> "${logFile}"\nexit ${exitCode}\n`) + fs.chmodSync(file, 0o755) + } - const failing = (command, args) => (args[0] === 'mcp' && args[1] === 'get' ? 1 : 0) + const calls = () => { + if (!fs.existsSync(logFile)) return [] + return fs.readFileSync(logFile, 'utf8').trim().split('\n') + } - const listFiles = () => fs.readdirSync(tempDir, { recursive: true }) const readJson = file => JSON.parse(fs.readFileSync(path.join(tempDir, file), 'utf8')) + before(function () { + if (process.platform === 'win32') this.skip() + }) + beforeEach(() => { originalCwd = process.cwd() + originalPath = process.env.PATH tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codecept-init-agent-'))) + binDir = path.join(tempDir, '.bin') + logFile = path.join(binDir, 'calls.log') + fs.mkdirSync(binDir) + process.env.PATH = `${binDir}${path.delimiter}${originalPath}` process.chdir(tempDir) - fs.writeFileSync(path.join(tempDir, 'codecept.conf.js'), 'export const config = {}') - calls = [] + fakeBin('claude') + fakeBin('codex') + fakeBin('npx') + process.exitCode = 0 }) afterEach(() => { process.chdir(originalCwd) + process.env.PATH = originalPath + process.exitCode = 0 fs.rmSync(tempDir, { recursive: true, force: true }) }) - describe('claude', () => { - it('registers MCP and installs skills without writing files', async () => { - const ready = await initAgent('claude', { yes: true, runner: runner(failing) }) - - expect(ready).to.be.true - expect(calls.filter(c => !c.quiet).map(c => [c.command, ...c.args])).to.deep.equal([ - ['claude', 'mcp', 'add', 'codeceptjs', '--', 'npx', 'codeceptjs-mcp'], - ['npx', 'skills', 'add', 'codeceptjs/skills', '-a', 'claude-code', '-y'], - ]) - expect(listFiles()).to.deep.equal(['codecept.conf.js']) - }) - - it('skips mcp add when the server is already registered', async () => { - await initAgent('claude', { yes: true, runner: runner() }) - - expect(calls.map(c => c.args.slice(0, 2).join(' '))).to.include('mcp get') - expect(calls.some(c => c.args[1] === 'add' && c.command === 'claude')).to.be.false - expect(calls.some(c => c.command === 'npx')).to.be.true - }) - - it('passes CODECEPTJS_CONFIG for a non-default config', async () => { - fs.mkdirSync(path.join(tempDir, 'e2e')) - fs.writeFileSync(path.join(tempDir, 'e2e/codecept.conf.ts'), 'export const config = {}') - - await initAgent('claude', { yes: true, config: 'e2e/codecept.conf.ts', runner: runner(failing) }) - - const add = calls.find(c => c.command === 'claude' && c.args[1] === 'add') - expect(add.args).to.deep.equal(['mcp', 'add', 'codeceptjs', '-e', 'CODECEPTJS_CONFIG=./e2e/codecept.conf.ts', '--', 'npx', 'codeceptjs-mcp']) - }) - - it('continues when the claude binary is missing', async () => { - const ready = await initAgent('claude', { yes: true, runner: runner(command => (command === 'claude' ? 127 : 0)) }) - - expect(ready).to.be.false - expect(calls.some(c => c.args[1] === 'add' && c.command === 'claude')).to.be.false - expect(calls.some(c => c.command === 'npx')).to.be.true - }) - - it('continues when skills install fails', async () => { - const ready = await initAgent('claude', { yes: true, runner: runner((command, args) => (command === 'npx' ? 1 : failing(command, args))) }) + it('claude: registers MCP with claude CLI and installs skills', async () => { + await initAgent('claude', { yes: true }) - expect(ready).to.be.false - expect(calls.some(c => c.command === 'claude' && c.args[1] === 'add')).to.be.true - }) + expect(calls()).to.deep.equal(['claude mcp add codeceptjs -- npx codeceptjs-mcp', 'npx skills add codeceptjs/skills -a claude-code -y']) + expect(fs.readdirSync(tempDir)).to.deep.equal(['.bin']) }) - describe('codex', () => { - it('registers MCP with codex and installs skills', async () => { - await initAgent('codex', { yes: true, runner: runner(failing) }) + it('codex: registers MCP with codex CLI and installs skills', async () => { + await initAgent('codex', { yes: true }) - expect(calls.filter(c => !c.quiet).map(c => [c.command, ...c.args])).to.deep.equal([ - ['codex', 'mcp', 'add', 'codeceptjs', '--', 'npx', 'codeceptjs-mcp'], - ['npx', 'skills', 'add', 'codeceptjs/skills', '-a', 'codex', '-y'], - ]) - expect(listFiles()).to.deep.equal(['codecept.conf.js']) - }) + expect(calls()).to.deep.equal(['codex mcp add codeceptjs -- npx codeceptjs-mcp', 'npx skills add codeceptjs/skills -a codex -y']) }) - describe('cursor', () => { - it('writes .cursor/mcp.json and installs skills', async () => { - await initAgent('cursor', { yes: true, runner: runner() }) + it('installs skills even when MCP registration fails', async () => { + fakeBin('claude', 1) - expect(readJson('.cursor/mcp.json')).to.deep.equal({ - mcpServers: { codeceptjs: { command: 'npx', args: ['codeceptjs-mcp'] } }, - }) - expect(calls.map(c => [c.command, ...c.args])).to.deep.equal([['npx', 'skills', 'add', 'codeceptjs/skills', '-a', 'cursor', '-y']]) - }) + await initAgent('claude', { yes: true }) - it('preserves other servers and keys', async () => { - fs.mkdirSync(path.join(tempDir, '.cursor')) - fs.writeFileSync(path.join(tempDir, '.cursor/mcp.json'), JSON.stringify({ other: true, mcpServers: { github: { command: 'gh' } } })) + expect(calls()).to.include('npx skills add codeceptjs/skills -a claude-code -y') + }) - await initAgent('cursor', { yes: true, runner: runner() }) + it('cursor: writes .cursor/mcp.json and keeps other servers', async () => { + fs.mkdirSync(path.join(tempDir, '.cursor')) + fs.writeFileSync(path.join(tempDir, '.cursor/mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'other' } } })) - expect(readJson('.cursor/mcp.json')).to.deep.equal({ - other: true, - mcpServers: { github: { command: 'gh' }, codeceptjs: { command: 'npx', args: ['codeceptjs-mcp'] } }, - }) - }) + await initAgent('cursor', { yes: true }) - it('produces the same file on a second run', async () => { - await initAgent('cursor', { yes: true, runner: runner() }) - const first = fs.readFileSync(path.join(tempDir, '.cursor/mcp.json'), 'utf8') - await initAgent('cursor', { yes: true, runner: runner() }) - expect(fs.readFileSync(path.join(tempDir, '.cursor/mcp.json'), 'utf8')).to.equal(first) + expect(readJson('.cursor/mcp.json')).to.deep.equal({ + mcpServers: { + other: { command: 'other' }, + codeceptjs: { command: 'npx', args: ['codeceptjs-mcp'] }, + }, }) + expect(calls()).to.deep.equal(['npx skills add codeceptjs/skills -a cursor -y']) + }) - it('leaves unparseable JSON untouched', async () => { - const jsonc = '{\n // comment\n "mcpServers": {}\n}\n' - fs.mkdirSync(path.join(tempDir, '.cursor')) - fs.writeFileSync(path.join(tempDir, '.cursor/mcp.json'), jsonc) + it('opencode: writes opencode.json, second run gives the same file', async () => { + fs.writeFileSync(path.join(tempDir, 'opencode.json'), JSON.stringify({ theme: 'dark' })) - const ready = await initAgent('cursor', { yes: true, runner: runner() }) + await initAgent('opencode', { yes: true }) + const first = fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8') + await initAgent('opencode', { yes: true }) - expect(ready).to.be.false - expect(fs.readFileSync(path.join(tempDir, '.cursor/mcp.json'), 'utf8')).to.equal(jsonc) - expect(calls.some(c => c.command === 'npx')).to.be.true + expect(fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8')).to.equal(first) + expect(readJson('opencode.json')).to.deep.equal({ + theme: 'dark', + mcp: { codeceptjs: { type: 'local', command: ['npx', 'codeceptjs-mcp'], enabled: true } }, }) }) - describe('opencode', () => { - it('writes opencode.json preserving other keys', async () => { - fs.writeFileSync(path.join(tempDir, 'opencode.json'), JSON.stringify({ $schema: 'https://opencode.ai/config.json', mcp: { other: { type: 'remote', url: 'http://x' } } })) - - await initAgent('opencode', { yes: true, runner: runner() }) + it('leaves unparseable JSON untouched', async () => { + const jsonc = '{\n // comment\n "mcp": {}\n}\n' + fs.writeFileSync(path.join(tempDir, 'opencode.json'), jsonc) - expect(readJson('opencode.json')).to.deep.equal({ - $schema: 'https://opencode.ai/config.json', - mcp: { - other: { type: 'remote', url: 'http://x' }, - codeceptjs: { type: 'local', command: ['npx', 'codeceptjs-mcp'], enabled: true }, - }, - }) - expect(calls.map(c => [c.command, ...c.args])).to.deep.equal([['npx', 'skills', 'add', 'codeceptjs/skills', '-a', 'opencode', '-y']]) - }) + await initAgent('opencode', { yes: true }) - it('produces the same file on a second run', async () => { - await initAgent('opencode', { yes: true, runner: runner() }) - const first = fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8') - await initAgent('opencode', { yes: true, runner: runner() }) - expect(fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8')).to.equal(first) - }) + expect(fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8')).to.equal(jsonc) }) - describe('arguments', () => { - afterEach(() => { - process.exitCode = 0 - }) - - it('requires an agent with --yes', async () => { - const ready = await initAgent(undefined, { yes: true, runner: runner() }) + it('fails on unknown agent', async () => { + await initAgent('vim', { yes: true }) - expect(ready).to.be.false - expect(process.exitCode).to.equal(1) - expect(calls).to.be.empty - expect(listFiles()).to.deep.equal(['codecept.conf.js']) - }) + expect(process.exitCode).to.equal(1) + expect(calls()).to.deep.equal([]) + }) - it('rejects unknown agents', async () => { - const ready = await initAgent('vim', { yes: true, runner: runner() }) + it('fails with --yes and no agent', async () => { + await initAgent(undefined, { yes: true }) - expect(ready).to.be.false - expect(process.exitCode).to.equal(1) - expect(calls).to.be.empty - }) + expect(process.exitCode).to.equal(1) }) })