import { createInterface } from 'readline';
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'fs';
import { execSync, execFileSync } from 'child_process';
import { join } from 'path';
import { homedir } from 'os';
import { DEFAULT_EXCHANGE_URL } from '../auth/token-manager.js';
import { validateExchangeUrl } from '../auth/exchange-url.js';
import { formatCodexCommandForDisplay, resolveCodexCliPath } from './codex-cli.js';
const CONFIG_DIR = join(homedir(), '.ice-puzzle-mcp');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
const SETUP_EXCHANGE_TIMEOUT_MS = 10000;
const MCP_SERVER_NAME = 'ice-puzzle-mcp';
const MCP_SERVER_ARGS = ['-y', MCP_SERVER_NAME, 'serve'];
interface SetupConfig {
apiKey: string;
exchangeTokenUrl?: string;
allowUntrustedExchangeUrl?: boolean;
firebaseProjectId?: string;
firebase?: {
apiKey?: string;
authDomain?: string;
projectId?: string;
storageBucket?: string;
messagingSenderId?: string;
appId?: string;
};
}
interface SetupOptions {
advanced?: boolean;
allowUntrustedExchangeUrl?: boolean;
}
function createReadline() {
return createInterface({
input: process.stdin,
output: process.stdout,
});
}
function question(rl: ReturnType<typeof createReadline>, prompt: string): Promise<string> {
return new Promise((resolve) => {
rl.question(prompt, (answer) => resolve(answer.trim()));
});
}
function readExistingConfig(): SetupConfig | null {
if (!existsSync(CONFIG_FILE)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed as SetupConfig;
} catch {
return null;
}
}
function ensureSecureConfigDir(): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
}
chmodSync(CONFIG_DIR, 0o700);
}
async function validateApiKey(apiKey: string, exchangeTokenUrl: string): Promise<{ ok: true; userId: string } | { ok: false; error: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SETUP_EXCHANGE_TIMEOUT_MS);
try {
const response = await fetch(exchangeTokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey }),
signal: controller.signal,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = (payload as { error?: string }).error || `HTTP ${response.status}`;
return { ok: false, error: message };
}
const userId = (payload as { userId?: string }).userId;
if (!userId || typeof userId !== 'string') {
return { ok: false, error: 'Token exchange succeeded but returned an invalid response.' };
}
return { ok: true, userId };
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return { ok: false, error: `Token exchange timed out after ${SETUP_EXCHANGE_TIMEOUT_MS}ms.` };
}
return {
ok: false,
error: error instanceof Error ? error.message : 'Network error during API key validation.',
};
} finally {
clearTimeout(timeout);
}
}
export async function runSetup(options: SetupOptions = {}) {
const advanced = options.advanced === true;
const allowUntrustedExchangeUrl = options.allowUntrustedExchangeUrl === true;
const rl = createReadline();
console.log('');
console.log(' ╔═══════════════════════════════════════════╗');
console.log(' ║ ICE PUZZLE MCP BUILDER ║');
console.log(' ║ Build levels with AI ║');
console.log(' ╚═══════════════════════════════════════════╝');
console.log('');
// Check for existing config
const existingConfig = readExistingConfig();
if (existsSync(CONFIG_FILE)) {
if (existingConfig?.apiKey) {
console.log(` Found existing config with API key: ${existingConfig.apiKey.substring(0, 12)}...`);
} else {
console.log(' Found existing config, but it is invalid. It will be replaced.');
}
const overwrite = await question(rl, ' Overwrite? (y/N): ');
if (overwrite.toLowerCase() !== 'y') {
console.log(' Keeping existing config.');
rl.close();
console.log('');
console.log(' Configure AI clients');
configureAiClients();
printPromptExamples();
return;
}
}
// Step 1: Account check
console.log(' Step 1/3: Account');
console.log(' You need an Ice Puzzle account to generate API keys.');
console.log(' Open https://ice-puzzle-game.web.app/ in your browser.');
console.log(' Sign in with Google, then go to Settings > Developer.');
console.log(' Generate an API key and paste it below.');
console.log('');
// Step 2: API Key
console.log(' Step 2/3: API Key');
const apiKey = await question(rl, ' Paste your API key (ipk_...): ');
if (!apiKey.startsWith('ipk_')) {
console.error(' Error: API key must start with "ipk_"');
rl.close();
process.exit(1);
}
const defaultExchangeUrl =
existingConfig?.exchangeTokenUrl ||
process.env.ICE_PUZZLE_EXCHANGE_TOKEN_URL ||
DEFAULT_EXCHANGE_URL;
let exchangeTokenUrl = defaultExchangeUrl;
if (advanced) {
console.log(' Advanced mode: Exchange URL override');
console.log(' Press Enter to keep the default exchange URL.');
const exchangeTokenUrlInput = await question(
rl,
` Exchange URL (optional) [${defaultExchangeUrl}]: `
);
exchangeTokenUrl = exchangeTokenUrlInput || defaultExchangeUrl;
} else {
console.log(' Using default exchange URL.');
console.log(' Re-run with "npx ice-puzzle-mcp setup --advanced" to override.');
}
const exchangeUrlValidation = validateExchangeUrl(exchangeTokenUrl, {
allowUntrustedHttpsHost: allowUntrustedExchangeUrl,
});
if (!exchangeUrlValidation.valid || !exchangeUrlValidation.normalizedUrl) {
console.error(` Error: ${exchangeUrlValidation.error}`);
if (!allowUntrustedExchangeUrl) {
console.error(' Tip: re-run setup with --allow-untrusted-exchange-url only if you trust the custom endpoint.');
}
rl.close();
process.exit(1);
}
const normalizedExchangeTokenUrl = exchangeUrlValidation.normalizedUrl;
// Validate the key by exchanging it once.
console.log(' Validating API key...');
const validation = await validateApiKey(apiKey, normalizedExchangeTokenUrl);
if (!validation.ok) {
console.error(` Error: API key validation failed: ${validation.error}`);
rl.close();
process.exit(1);
}
console.log(` Valid key for user: ${validation.userId}`);
// Save config
ensureSecureConfigDir();
const config: SetupConfig = {
apiKey,
exchangeTokenUrl: normalizedExchangeTokenUrl,
allowUntrustedExchangeUrl,
};
// Preserve existing custom Firebase overrides when present.
if (existingConfig?.firebase) {
config.firebase = existingConfig.firebase;
} else if (existingConfig?.firebaseProjectId) {
config.firebaseProjectId = existingConfig.firebaseProjectId;
}
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
chmodSync(CONFIG_FILE, 0o600);
console.log(` Config saved to ${CONFIG_FILE}`);
console.log('');
rl.close();
// Step 3: Configure supported AI clients.
console.log(' Step 3/3: Configure AI clients');
configureAiClients();
printPromptExamples();
}
function printPromptExamples(): void {
console.log('');
console.log(' All set! Try these in Claude Code or Codex:');
console.log('');
console.log(' "Build a medium 13x11 level with rocks, lava, and hot coals; target 12 moves and set par to shortest."');
console.log(' "Create a hard 16x14 puzzle with warps, thin ice, and pushable rocks, plus 2 deceptive branches; make validate_quality_gate(requirePar=true) pass."');
console.log(' "Design a pressure plate + barrier level where the plate is mandatory before goal, then run solve_level, analyze_difficulty, and visualize_level."');
console.log('');
}
function upsertClaudeMcpConfigFile(
mcpConfigFile: string,
desiredServer: { command: string; args: string[] },
desiredArgs: string[],
): void {
let mcpConfig: any = {};
let indent: string | number = 2;
let trailingNewline = true;
const backupExistingConfig = (raw: string): void => {
const backupPath = mcpConfigFile + '.backup';
writeFileSync(backupPath, raw, { mode: 0o600 });
chmodSync(backupPath, 0o600);
console.log(` Backup saved to ${backupPath}`);
};
if (existsSync(mcpConfigFile)) {
const raw = readFileSync(mcpConfigFile, 'utf-8');
trailingNewline = raw.endsWith('\n');
// Detect indentation from existing file
const indentMatch = raw.match(/^(\s+)"/m);
if (indentMatch) {
indent = indentMatch[1].includes('\t') ? '\t' : indentMatch[1].length;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
console.error(` Warning: existing ${mcpConfigFile} root must be a JSON object.`);
backupExistingConfig(raw);
mcpConfig = {};
} else {
mcpConfig = parsed;
}
} catch {
console.error(` Warning: existing ${mcpConfigFile} has invalid JSON.`);
backupExistingConfig(raw);
mcpConfig = {};
}
}
if (!mcpConfig.mcpServers || typeof mcpConfig.mcpServers !== 'object' || Array.isArray(mcpConfig.mcpServers)) {
mcpConfig.mcpServers = {};
}
const mcpServers = mcpConfig.mcpServers as Record<string, any>;
const parseArgs = (value: any): string[] => (Array.isArray(value) ? value.filter((x) => typeof x === 'string') : []);
const isIcePuzzleServer = (entry: any): boolean =>
entry
&& typeof entry === 'object'
&& typeof entry.command === 'string'
&& parseArgs(entry.args).includes(MCP_SERVER_NAME);
const isDesiredServer = (entry: any): boolean =>
entry
&& entry.command === desiredServer.command
&& JSON.stringify(parseArgs(entry.args)) === JSON.stringify(desiredArgs);
const getUniqueKey = (baseKey: string): string => {
if (!(baseKey in mcpServers)) return baseKey;
let i = 2;
while (`${baseKey}-${i}` in mcpServers) {
i++;
}
return `${baseKey}-${i}`;
};
const existingIcePuzzleKeys = Object.entries(mcpServers)
.filter(([, value]) => isIcePuzzleServer(value))
.map(([key]) => key);
if (existingIcePuzzleKeys.length > 0) {
// Prefer canonical key if available.
let selectedKey = existingIcePuzzleKeys.includes('ice-puzzle-mcp')
? MCP_SERVER_NAME
: existingIcePuzzleKeys[0];
if (selectedKey === 'ice-puzzle' && !(MCP_SERVER_NAME in mcpServers)) {
mcpServers[MCP_SERVER_NAME] = mcpServers[selectedKey];
delete mcpServers[selectedKey];
selectedKey = MCP_SERVER_NAME;
console.log(` Migrated MCP key from "ice-puzzle" to "${MCP_SERVER_NAME}" in ${mcpConfigFile}.`);
}
if (!isDesiredServer(mcpServers[selectedKey])) {
mcpServers[selectedKey] = desiredServer;
console.log(` Updated existing MCP server entry: ${selectedKey} (${mcpConfigFile})`);
} else {
console.log(` Claude Code already configured with ice-puzzle MCP (${selectedKey}) in ${mcpConfigFile}.`);
}
} else {
const serverKey = getUniqueKey(MCP_SERVER_NAME);
mcpServers[serverKey] = desiredServer;
console.log(` Added ice-puzzle MCP server as "${serverKey}" in ${mcpConfigFile}.`);
}
let output = JSON.stringify(mcpConfig, null, indent);
if (trailingNewline) output += '\n';
writeFileSync(mcpConfigFile, output, { mode: 0o600 });
chmodSync(mcpConfigFile, 0o600);
console.log(` Config: ${mcpConfigFile}`);
}
function isCommandAvailable(command: string): boolean {
try {
execSync(`${command} --version`, { stdio: 'pipe', timeout: 5000 });
return true;
} catch {
return false;
}
}
function tryClaudeCli(): boolean {
if (!isCommandAvailable('claude')) {
return false;
}
const addCmd = `claude mcp add --scope user ${MCP_SERVER_NAME} -- npx -y ${MCP_SERVER_NAME} serve`;
try {
execSync(addCmd, { stdio: 'pipe', timeout: 15000 });
console.log(` Registered ${MCP_SERVER_NAME} with Claude Code.`);
return true;
} catch {
// Server may already exist with this name — remove and re-add.
try {
execSync(`claude mcp remove --scope user ${MCP_SERVER_NAME}`, { stdio: 'pipe', timeout: 10000 });
execSync(addCmd, { stdio: 'pipe', timeout: 15000 });
console.log(` Updated ${MCP_SERVER_NAME} registration in Claude Code.`);
return true;
} catch {
return false;
}
}
}
function configureClaudeCode(): boolean {
// Primary: use the `claude` CLI to register the MCP server directly.
if (tryClaudeCli()) {
console.log(' Restart Claude Code to activate the tools.');
return true;
}
const claudeConfigDir = join(homedir(), '.claude');
// Fallback: write to config files for older Claude Code versions or when CLI is unavailable.
console.log(' Claude CLI not available. Configuring via config files instead.');
const preferredConfigFiles = [
join(claudeConfigDir, '.mcp.json'),
join(claudeConfigDir, 'mcp.json'),
];
const existingConfigFiles = preferredConfigFiles.filter((filePath) => existsSync(filePath));
const targetConfigFiles = existingConfigFiles.length > 0
? existingConfigFiles
: [preferredConfigFiles[0]];
const desiredArgs = [...MCP_SERVER_ARGS];
const desiredServer = {
command: 'npx',
args: desiredArgs,
};
if (!existsSync(claudeConfigDir)) {
mkdirSync(claudeConfigDir, { recursive: true, mode: 0o700 });
}
chmodSync(claudeConfigDir, 0o700);
if (existingConfigFiles.length === 0) {
console.log(` No existing Claude MCP config found. Creating ${targetConfigFiles[0]}.`);
} else if (existingConfigFiles.length > 1) {
console.log(' Found both .mcp.json and mcp.json. Updating both to keep Claude MCP config in sync.');
}
for (const mcpConfigFile of targetConfigFiles) {
upsertClaudeMcpConfigFile(mcpConfigFile, desiredServer, desiredArgs);
}
console.log(' Restart Claude Code to activate the tools.');
return true;
}
function tryCodexCli(codexCommand: string): boolean {
const addArgs = ['mcp', 'add', MCP_SERVER_NAME, '--', 'npx', '-y', MCP_SERVER_NAME, 'serve'];
try {
execFileSync(codexCommand, addArgs, { stdio: 'pipe', timeout: 15000 });
console.log(` Registered ${MCP_SERVER_NAME} with Codex.`);
return true;
} catch {
// Server may already exist with this name — remove and re-add.
try {
execFileSync(codexCommand, ['mcp', 'remove', MCP_SERVER_NAME], { stdio: 'pipe', timeout: 10000 });
execFileSync(codexCommand, addArgs, { stdio: 'pipe', timeout: 15000 });
console.log(` Updated ${MCP_SERVER_NAME} registration in Codex.`);
return true;
} catch {
return false;
}
}
}
function configureCodex(): boolean {
const codexCommand = resolveCodexCliPath();
if (!codexCommand) {
console.log(' Codex CLI not found in PATH or standard app locations. Skipping Codex MCP registration.');
return false;
}
if (codexCommand !== 'codex') {
console.log(` Found Codex CLI at ${codexCommand}.`);
}
if (tryCodexCli(codexCommand)) {
console.log(' Restart Codex to activate the tools.');
return true;
}
console.log(' Codex is installed, but MCP registration failed.');
const codexDisplayCommand = formatCodexCommandForDisplay(codexCommand);
console.log(` Try manually: ${codexDisplayCommand} mcp add ${MCP_SERVER_NAME} -- npx -y ${MCP_SERVER_NAME} serve`);
return false;
}
function configureAiClients(): void {
try {
configureClaudeCode();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Warning: Claude configuration failed: ${message}`);
}
console.log('');
try {
configureCodex();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(` Warning: Codex configuration failed: ${message}`);
}
}