From 09efd682284ffb8f3d9a0f4960fd382e0db94cda Mon Sep 17 00:00:00 2001 From: Chris Yau Date: Mon, 23 Mar 2026 06:39:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20safe=20Codex=20config=20sync=20=E2=80=94?= =?UTF-8?q?=20merge=20AGENTS.md=20+=20add-only=20MCP=20servers=20(#723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: replace bash TOML surgery with Node add-only MCP merge The old sync script used awk/sed to remove and re-append MCP server sections in config.toml, causing credential extraction races, duplicate TOML tables, and 3 fragile code paths with 9 remove_section_inplace calls each. Replace with a Node script (scripts/codex/merge-mcp-config.js) that uses @iarna/toml to parse the config, then appends only missing ECC servers — preserving all existing content byte-for-byte. Warns on config drift, supports legacy aliases (context7 → context7-mcp), and adds --update-mcp flag for explicit refresh. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * fix: address PR #723 review findings for Codex MCP merge - Use package-manager abstraction (scripts/lib/package-manager.js) instead of hardcoding pnpm — respects CLAUDE_PACKAGE_MANAGER, lock files, and project config - Add Yarn 1.x fallback to npx (yarn dlx unsupported in classic) - Add missing exa server to match .codex/config.toml baseline - Wire up findSubSections for --update-mcp nested subtable removal (fixes Greptile P1: Object.keys only returned top-level keys) - Fix resolvedLabel to prefer canonical entry over legacy alias when both exist (fixes context7/context7-mcp spurious warning) - Fix removeSectionFromText to handle inline TOML comments - Fix dry-run + --update-mcp to show removals before early return - Update README parity table: 4 → 7 servers, TOML-parser-based - Add non-npm install variants to README Codex quick start - Update package-lock.json for @iarna/toml Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * fix: address PR #723 review comments (preflight, marker validation) - Add Node.js and merge-mcp-config.js to preflight checks so the script fails fast before partial writes (CodeRabbit) - Validate marker counts: require exactly 1 BEGIN + 1 END in correct order for clean replacement (CodeRabbit) - Corrupted markers: strip all marker lines and re-append fresh block, preserving user content outside markers instead of overwriting - Move MCP_MERGE_SCRIPT to preflight section, remove duplicate Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --------- Co-authored-by: Claude Co-authored-by: Happy --- .codex/AGENTS.md | 11 ++ README.md | 14 +- package-lock.json | 7 + package.json | 2 + scripts/codex/merge-mcp-config.js | 304 ++++++++++++++++++++++++++++++ scripts/sync-ecc-to-codex.sh | 120 +++++------- 6 files changed, 382 insertions(+), 76 deletions(-) create mode 100644 scripts/codex/merge-mcp-config.js diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md index 60f63920..ac93fb49 100644 --- a/.codex/AGENTS.md +++ b/.codex/AGENTS.md @@ -46,6 +46,17 @@ Available skills: Treat the project-local `.codex/config.toml` as the default Codex baseline for ECC. The current ECC baseline enables GitHub, Context7, Exa, Memory, Playwright, and Sequential Thinking; add heavier extras in `~/.codex/config.toml` only when a task actually needs them. +### Automatic config.toml merging + +The sync script (`scripts/sync-ecc-to-codex.sh`) uses a Node-based TOML parser to safely merge ECC MCP servers into `~/.codex/config.toml`: + +- **Add-only by default** — missing ECC servers are appended; existing servers are never modified or removed. +- **7 managed servers** — Supabase, Playwright, Context7, Exa, GitHub, Memory, Sequential Thinking. +- **Package-manager aware** — uses the project's configured package manager (npm/pnpm/yarn/bun) instead of hardcoding `pnpm`. +- **Drift warnings** — if an existing server's config differs from the ECC recommendation, the script logs a warning. +- **`--update-mcp`** — explicitly replaces all ECC-managed servers with the latest recommended config (safely removes subtables like `[mcp_servers.supabase.env]`). +- **User config is always preserved** — custom servers, args, env vars, and credentials outside ECC-managed sections are never touched. + ## Multi-Agent Support Codex now supports multi-agent workflows behind the experimental `features.multi_agent` flag. diff --git a/README.md b/README.md index 9ca55c08..a4a1f4ef 100644 --- a/README.md +++ b/README.md @@ -986,10 +986,18 @@ ECC provides **first-class Codex support** for both the macOS app and CLI, with # Run Codex CLI in the repo — AGENTS.md and .codex/ are auto-detected codex -# Optional: copy the global-safe defaults to your home directory +# Automatic setup: sync ECC assets (AGENTS.md, skills, MCP servers) into ~/.codex +npm install && bash scripts/sync-ecc-to-codex.sh +# or: pnpm install && bash scripts/sync-ecc-to-codex.sh +# or: yarn install && bash scripts/sync-ecc-to-codex.sh +# or: bun install && bash scripts/sync-ecc-to-codex.sh + +# Or manually: copy the reference config to your home directory cp .codex/config.toml ~/.codex/config.toml ``` +The sync script safely merges ECC MCP servers into your existing `~/.codex/config.toml` using an **add-only** strategy — it never removes or modifies your existing servers. Run with `--dry-run` to preview changes, or `--update-mcp` to force-refresh ECC servers to the latest recommended config. + Codex macOS app: - Open this repository as your workspace. - The root `AGENTS.md` is auto-detected. @@ -1004,7 +1012,7 @@ Codex macOS app: | Config | 1 | `.codex/config.toml` — top-level approvals/sandbox/web_search, MCP servers, notifications, profiles | | AGENTS.md | 2 | Root (universal) + `.codex/AGENTS.md` (Codex-specific supplement) | | Skills | 16 | `.agents/skills/` — SKILL.md + agents/openai.yaml per skill | -| MCP Servers | 4 | GitHub, Context7, Memory, Sequential Thinking (command-based) | +| MCP Servers | 6 | Supabase, Playwright, Context7, GitHub, Memory, Sequential Thinking (auto-merged via add-only sync) | | Profiles | 2 | `strict` (read-only sandbox) and `yolo` (full auto-approve) | | Agent Roles | 3 | `.codex/agents/` — explorer, reviewer, docs-researcher | @@ -1189,7 +1197,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | | **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | | **Custom Tools** | Via hooks | Via hooks | N/A | 6 native tools | -| **MCP Servers** | 14 | Shared (mcp.json) | 4 (command-based) | Full | +| **MCP Servers** | 14 | Shared (mcp.json) | 7 (auto-merged via TOML parser) | Full | | **Config Format** | settings.json | hooks.json + rules/ | config.toml | opencode.json | | **Context File** | CLAUDE.md + AGENTS.md | AGENTS.md | AGENTS.md | AGENTS.md | | **Secret Detection** | Hook-based | beforeSubmitPrompt hook | Sandbox-based | Hook-based | diff --git a/package-lock.json b/package-lock.json index e0b59f9f..4ab265da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@iarna/toml": "^2.2.5", "sql.js": "^1.14.1" }, "bin": { @@ -271,6 +272,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/package.json b/package.json index 822234f8..5e82a1b3 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "scripts/orchestrate-worktrees.js", "scripts/setup-package-manager.js", "scripts/skill-create-output.js", + "scripts/codex/merge-mcp-config.js", "scripts/repair.js", "scripts/harness-audit.js", "scripts/session-inspect.js", @@ -108,6 +109,7 @@ "coverage": "c8 --all --include=\"scripts/**/*.js\" --check-coverage --lines 80 --functions 80 --branches 80 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js" }, "dependencies": { + "@iarna/toml": "^2.2.5", "sql.js": "^1.14.1" }, "devDependencies": { diff --git a/scripts/codex/merge-mcp-config.js b/scripts/codex/merge-mcp-config.js new file mode 100644 index 00000000..c75710b7 --- /dev/null +++ b/scripts/codex/merge-mcp-config.js @@ -0,0 +1,304 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Merge ECC-recommended MCP servers into a Codex config.toml. + * + * Strategy: ADD-ONLY by default. + * - Parse the TOML to detect which mcp_servers.* sections exist. + * - Append raw TOML text for any missing servers (preserves existing file byte-for-byte). + * - Log warnings when an existing server's config differs from the ECC recommendation. + * - With --update-mcp, also replace existing ECC-managed servers. + * + * Uses the repo's package-manager abstraction (scripts/lib/package-manager.js) + * so MCP launcher commands respect the user's configured package manager. + * + * Usage: + * node merge-mcp-config.js [--dry-run] [--update-mcp] + */ + +const fs = require('fs'); +const path = require('path'); + +let TOML; +try { + TOML = require('@iarna/toml'); +} catch { + console.error('[ecc-mcp] Missing dependency: @iarna/toml'); + console.error('[ecc-mcp] Run: npm install (from the ECC repo root)'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Package manager detection +// --------------------------------------------------------------------------- + +let pmConfig; +try { + const { getPackageManager } = require(path.join(__dirname, '..', 'lib', 'package-manager.js')); + pmConfig = getPackageManager(); +} catch { + // Fallback: if package-manager.js isn't available, default to npx + pmConfig = { name: 'npm', config: { name: 'npm', execCmd: 'npx' } }; +} + +// Yarn 1.x doesn't support `yarn dlx` — fall back to npx for classic Yarn. +let resolvedExecCmd = pmConfig.config.execCmd; +if (pmConfig.name === 'yarn' && resolvedExecCmd === 'yarn dlx') { + try { + const { execFileSync } = require('child_process'); + const ver = execFileSync('yarn', ['--version'], { encoding: 'utf8', timeout: 5000 }).trim(); + if (ver.startsWith('1.')) { + resolvedExecCmd = 'npx'; + } + } catch { + // Can't detect version — keep yarn dlx and let it fail visibly + } +} + +const PM_NAME = pmConfig.config.name || pmConfig.name; +const PM_EXEC = resolvedExecCmd; // e.g. "pnpm dlx", "npx", "bunx", "yarn dlx" +const PM_EXEC_PARTS = PM_EXEC.split(/\s+/); // ["pnpm", "dlx"] or ["npx"] or ["bunx"] + +// --------------------------------------------------------------------------- +// ECC-recommended MCP servers +// --------------------------------------------------------------------------- + +// GitHub bootstrap uses bash for token forwarding — this is intentionally +// shell-based regardless of package manager, since Codex runs on macOS/Linux. +const GH_BOOTSTRAP = `token=$(gh auth token 2>/dev/null || true); if [ -n "$token" ]; then export GITHUB_PERSONAL_ACCESS_TOKEN="$token"; fi; exec ${PM_EXEC} @modelcontextprotocol/server-github`; + +/** + * Build a server spec with the detected package manager. + * Returns { fields, toml } where fields is for drift detection and + * toml is the raw text appended to the file. + */ +function dlxServer(name, pkg, extraFields, extraToml) { + const args = [...PM_EXEC_PARTS.slice(1), pkg]; + const fields = { command: PM_EXEC_PARTS[0], args, ...extraFields }; + const argsStr = JSON.stringify(args).replace(/,/g, ', '); + let toml = `[mcp_servers.${name}]\ncommand = "${PM_EXEC_PARTS[0]}"\nargs = ${argsStr}`; + if (extraToml) toml += '\n' + extraToml; + return { fields, toml }; +} + +/** Each entry: key = section name under mcp_servers, value = { toml, fields } */ +const ECC_SERVERS = { + supabase: dlxServer('supabase', '@supabase/mcp-server-supabase@latest', { startup_timeout_sec: 20.0, tool_timeout_sec: 120.0 }, 'startup_timeout_sec = 20.0\ntool_timeout_sec = 120.0'), + playwright: dlxServer('playwright', '@playwright/mcp@latest'), + 'context7-mcp': dlxServer('context7-mcp', '@upstash/context7-mcp'), + exa: { + fields: { url: 'https://mcp.exa.ai/mcp' }, + toml: `[mcp_servers.exa]\nurl = "https://mcp.exa.ai/mcp"` + }, + github: { + fields: { command: 'bash', args: ['-lc', GH_BOOTSTRAP] }, + toml: `[mcp_servers.github]\ncommand = "bash"\nargs = ["-lc", ${JSON.stringify(GH_BOOTSTRAP)}]` + }, + memory: dlxServer('memory', '@modelcontextprotocol/server-memory'), + 'sequential-thinking': dlxServer('sequential-thinking', '@modelcontextprotocol/server-sequential-thinking') +}; + +// Append --features arg for supabase after dlxServer builds the base +ECC_SERVERS.supabase.fields.args.push('--features=account,docs,database,debugging,development,functions,storage,branching'); +ECC_SERVERS.supabase.toml = ECC_SERVERS.supabase.toml.replace(/^(args = \[.*)\]$/m, '$1, "--features=account,docs,database,debugging,development,functions,storage,branching"]'); + +// Legacy section names that should be treated as an existing ECC server. +// e.g. old configs shipped [mcp_servers.context7] instead of [mcp_servers.context7-mcp]. +const LEGACY_ALIASES = { + 'context7-mcp': ['context7'] +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function log(msg) { + console.log(`[ecc-mcp] ${msg}`); +} + +function warn(msg) { + console.warn(`[ecc-mcp] WARNING: ${msg}`); +} + +/** Shallow-compare two objects (one level deep, arrays by JSON). */ +function configDiffers(existing, recommended) { + for (const key of Object.keys(recommended)) { + const a = existing[key]; + const b = recommended[key]; + if (Array.isArray(b)) { + if (JSON.stringify(a) !== JSON.stringify(b)) return true; + } else if (a !== b) { + return true; + } + } + return false; +} + +/** + * Remove a TOML section and its key-value pairs from raw text. + * Matches the section header even if followed by inline comments or whitespace + * (e.g. `[mcp_servers.github] # comment`). + * Returns the text with the section removed. + */ +function removeSectionFromText(text, sectionHeader) { + const escaped = sectionHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const headerPattern = new RegExp(`^${escaped}(\\s*(#.*)?)?$`); + const lines = text.split('\n'); + const result = []; + let skipping = false; + for (const line of lines) { + const trimmed = line.replace(/\r$/, ''); + if (headerPattern.test(trimmed)) { + skipping = true; + continue; + } + if (skipping && /^\[/.test(trimmed)) { + skipping = false; + } + if (!skipping) { + result.push(line); + } + } + return result.join('\n'); +} + +/** + * Collect all TOML sub-section headers for a given server name. + * @iarna/toml nests subtables, so `[mcp_servers.supabase.env]` appears as + * `parsed.mcp_servers.supabase.env` (nested), NOT as a flat dotted key. + * Walk the nested object to find sub-objects that represent TOML sub-tables. + */ +function findSubSections(serverObj, prefix) { + const sections = []; + if (!serverObj || typeof serverObj !== 'object') return sections; + for (const key of Object.keys(serverObj)) { + const val = serverObj[key]; + if (val && typeof val === 'object' && !Array.isArray(val)) { + const subPath = `${prefix}.${key}`; + sections.push(subPath); + sections.push(...findSubSections(val, subPath)); + } + } + return sections; +} + +/** + * Remove a server and all its sub-sections from raw TOML text. + * Uses findSubSections to walk the parsed nested object (not flat keys). + */ +function removeServerFromText(raw, serverName, existing) { + let result = removeSectionFromText(raw, `[mcp_servers.${serverName}]`); + const serverObj = existing[serverName]; + if (serverObj) { + for (const sub of findSubSections(serverObj, serverName)) { + result = removeSectionFromText(result, `[mcp_servers.${sub}]`); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main() { + const args = process.argv.slice(2); + const configPath = args.find(a => !a.startsWith('-')); + const dryRun = args.includes('--dry-run'); + const updateMcp = args.includes('--update-mcp'); + + if (!configPath) { + console.error('Usage: merge-mcp-config.js [--dry-run] [--update-mcp]'); + process.exit(1); + } + + if (!fs.existsSync(configPath)) { + console.error(`[ecc-mcp] Config file not found: ${configPath}`); + process.exit(1); + } + + log(`Package manager: ${PM_NAME} (exec: ${PM_EXEC})`); + + let raw = fs.readFileSync(configPath, 'utf8'); + let parsed; + try { + parsed = TOML.parse(raw); + } catch (err) { + console.error(`[ecc-mcp] Failed to parse ${configPath}: ${err.message}`); + process.exit(1); + } + + const existing = parsed.mcp_servers || {}; + const toAppend = []; + const toRemoveLog = []; + + for (const [name, spec] of Object.entries(ECC_SERVERS)) { + const entry = existing[name]; + const aliases = LEGACY_ALIASES[name] || []; + const legacyName = aliases.find(a => existing[a] && typeof existing[a].command === 'string'); + + // Prefer canonical entry over legacy alias + const hasCanonical = entry && typeof entry.command === 'string'; + const resolvedEntry = hasCanonical ? entry : legacyName ? existing[legacyName] : null; + // For URL-based servers (exa), check for url field instead of command + const urlEntry = !resolvedEntry && entry && typeof entry.url === 'string' ? entry : null; + const finalEntry = resolvedEntry || urlEntry; + const resolvedLabel = hasCanonical ? name : legacyName || name; + + if (finalEntry) { + if (updateMcp) { + // --update-mcp: remove existing section (and legacy alias), will re-add below + toRemoveLog.push(`mcp_servers.${resolvedLabel}`); + raw = removeServerFromText(raw, resolvedLabel, existing); + if (resolvedLabel !== name) { + raw = removeServerFromText(raw, name, existing); + } + toAppend.push(spec.toml); + } else { + // Add-only mode: skip, but warn about drift + if (legacyName && !hasCanonical) { + warn(`mcp_servers.${legacyName} is a legacy name for ${name} (run with --update-mcp to migrate)`); + } else if (configDiffers(finalEntry, spec.fields)) { + warn(`mcp_servers.${name} differs from ECC recommendation (run with --update-mcp to refresh)`); + } else { + log(` [ok] mcp_servers.${name}`); + } + } + } else { + log(` [add] mcp_servers.${name}`); + toAppend.push(spec.toml); + } + } + + if (toAppend.length === 0) { + log('All ECC MCP servers already present. Nothing to do.'); + return; + } + + const appendText = '\n' + toAppend.join('\n\n') + '\n'; + + if (dryRun) { + if (toRemoveLog.length > 0) { + log('Dry run — would remove and re-add:'); + for (const label of toRemoveLog) log(` [remove] ${label}`); + } + log('Dry run — would append:'); + console.log(appendText); + return; + } + + // Write: for add-only, append to preserve existing content byte-for-byte. + // For --update-mcp, we modified `raw` above, so write the full file + appended sections. + if (updateMcp) { + for (const label of toRemoveLog) log(` [update] ${label}`); + const cleaned = raw.replace(/\n+$/, '\n'); + fs.writeFileSync(configPath, cleaned + appendText, 'utf8'); + } else { + fs.appendFileSync(configPath, appendText, 'utf8'); + } + + log(`Done. ${toAppend.length} server(s) ${updateMcp ? 'updated' : 'added'}.`); +} + +main(); diff --git a/scripts/sync-ecc-to-codex.sh b/scripts/sync-ecc-to-codex.sh index 557f8503..90db5caa 100644 --- a/scripts/sync-ecc-to-codex.sh +++ b/scripts/sync-ecc-to-codex.sh @@ -9,12 +9,16 @@ set -euo pipefail # - Generates Codex QA wrappers and optional language rule-pack prompts # - Installs global git safety hooks (pre-commit and pre-push) # - Runs a post-sync global regression sanity check -# - Normalizes MCP server entries to pnpm dlx and removes duplicate Context7 block +# - Merges ECC MCP servers into config.toml (add-only via Node TOML parser) MODE="apply" -if [[ "${1:-}" == "--dry-run" ]]; then - MODE="dry-run" -fi +UPDATE_MCP="" +for arg in "$@"; do + case "$arg" in + --dry-run) MODE="dry-run" ;; + --update-mcp) UPDATE_MCP="--update-mcp" ;; + esac +done SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -123,6 +127,8 @@ generate_prompt_file() { } > "$out" } +MCP_MERGE_SCRIPT="$REPO_ROOT/scripts/codex/merge-mcp-config.js" + require_path "$REPO_ROOT/AGENTS.md" "ECC AGENTS.md" require_path "$AGENTS_CODEX_SUPP_SRC" "ECC Codex AGENTS supplement" require_path "$SKILLS_SRC" "ECC skills directory" @@ -131,6 +137,12 @@ require_path "$HOOKS_INSTALLER" "ECC global git hooks installer" require_path "$SANITY_CHECKER" "ECC global sanity checker" require_path "$CURSOR_RULES_DIR" "ECC Cursor rules directory" require_path "$CONFIG_FILE" "Codex config.toml" +require_path "$MCP_MERGE_SCRIPT" "ECC MCP merge script" + +if ! command -v node >/dev/null 2>&1; then + log "ERROR: node is required for MCP config merging but was not found" + exit 1 +fi log "Mode: $MODE" log "Repo root: $REPO_ROOT" @@ -183,22 +195,36 @@ else compose_ecc_block > "$AGENTS_FILE" elif awk -v b="$ECC_BEGIN_MARKER" -v e="$ECC_END_MARKER" ' { gsub(/\r$/, "") } - $0 == b { found_b = NR } $0 == e { found_e = NR } - END { exit !(found_b && found_e && found_b < found_e) } + $0 == b { bc++; if (!fb) fb = NR } + $0 == e { ec++; if (!fe) fe = NR } + END { exit !(bc == 1 && ec == 1 && fb < fe) } ' "$AGENTS_FILE"; then - # Existing file with matched, correctly ordered ECC markers — replace only the ECC section + # Exactly one BEGIN/END pair in correct order — replace only the ECC section replace_ecc_section - elif grep -qF "$ECC_BEGIN_MARKER" "$AGENTS_FILE"; then - # BEGIN marker exists but END marker is missing (corrupted). Warn and - # replace the file entirely to restore a valid state. Backup was saved. - log "WARNING: found BEGIN marker but no END marker — replacing file (backup saved)" - compose_ecc_block > "$AGENTS_FILE" + elif awk -v b="$ECC_BEGIN_MARKER" -v e="$ECC_END_MARKER" ' + { gsub(/\r$/, "") } + $0 == b { bc++ } $0 == e { ec++ } + END { exit !((bc + ec) > 0) } + ' "$AGENTS_FILE"; then + # Markers present but not exactly one valid BEGIN/END pair (missing END, + # duplicates, or out-of-order). Strip all marker lines, then append a + # fresh marked block. This preserves user content outside markers. + log "WARNING: ECC markers found but not a clean pair — stripping markers and re-appending" + _fix_tmp="$(mktemp)" + awk -v b="$ECC_BEGIN_MARKER" -v e="$ECC_END_MARKER" ' + { gsub(/\r$/, "") } + $0 == b { skip = 1; next } + $0 == e { skip = 0; next } + !skip { print } + ' "$AGENTS_FILE" > "$_fix_tmp" + cat "$_fix_tmp" > "$AGENTS_FILE" + rm -f "$_fix_tmp" + { printf '\n\n'; compose_ecc_block; } >> "$AGENTS_FILE" else - # Existing file without markers — append ECC block, preserve user content. - # Note: legacy ECC-only files (from old '>' overwrite) will get a second copy - # on this first run. This is intentional — the alternative (heading-match - # heuristic) risks false-positive overwrites of user-authored files. The next - # run deduplicates via markers, and a timestamped backup was saved above. + # Existing file without markers — append ECC block, preserving existing content. + # Legacy ECC-only files will have duplicate content after this first run, but + # subsequent runs use marker-based replacement so only the marked section updates. + # A timestamped backup was already saved above for recovery if needed. log "No ECC markers found — appending managed block (backup saved)" { printf '\n\n' @@ -435,63 +461,11 @@ if [[ "$MODE" == "apply" ]]; then sort -u "$extension_manifest" -o "$extension_manifest" fi -if [[ "$MODE" == "apply" ]]; then - log "Normalizing MCP server config to pnpm" - - supabase_token="$(extract_toml_value "$CONFIG_FILE" "mcp_servers.supabase.env" "SUPABASE_ACCESS_TOKEN")" - context7_key="$(extract_context7_key "$CONFIG_FILE")" - github_bootstrap='token=$(gh auth token 2>/dev/null || true); if [ -n "$token" ]; then export GITHUB_PERSONAL_ACCESS_TOKEN="$token"; fi; exec pnpm dlx @modelcontextprotocol/server-github' - - remove_section_inplace "$CONFIG_FILE" "mcp_servers.github.env" - remove_section_inplace "$CONFIG_FILE" "mcp_servers.github" - remove_section_inplace "$CONFIG_FILE" "mcp_servers.memory" - remove_section_inplace "$CONFIG_FILE" "mcp_servers.sequential-thinking" - remove_section_inplace "$CONFIG_FILE" "mcp_servers.context7" - remove_section_inplace "$CONFIG_FILE" "mcp_servers.context7-mcp" - remove_section_inplace "$CONFIG_FILE" "mcp_servers.playwright" - remove_section_inplace "$CONFIG_FILE" "mcp_servers.supabase.env" - remove_section_inplace "$CONFIG_FILE" "mcp_servers.supabase" - - { - printf '\n[mcp_servers.supabase]\n' - printf 'command = "pnpm"\n' - printf 'args = ["dlx", "@supabase/mcp-server-supabase@latest", "--features=account,docs,database,debugging,development,functions,storage,branching"]\n' - printf 'startup_timeout_sec = 20.0\n' - printf 'tool_timeout_sec = 120.0\n' - - if [[ -n "$supabase_token" ]]; then - printf '\n[mcp_servers.supabase.env]\n' - printf 'SUPABASE_ACCESS_TOKEN = "%s"\n' "$(toml_escape "$supabase_token")" - fi - - printf '\n[mcp_servers.playwright]\n' - printf 'command = "pnpm"\n' - printf 'args = ["dlx", "@playwright/mcp@latest"]\n' - - if [[ -n "$context7_key" ]]; then - printf '\n[mcp_servers.context7-mcp]\n' - printf 'command = "pnpm"\n' - printf 'args = ["dlx", "@smithery/cli@latest", "run", "@upstash/context7-mcp", "--key", "%s"]\n' "$(toml_escape "$context7_key")" - else - printf '\n[mcp_servers.context7-mcp]\n' - printf 'command = "pnpm"\n' - printf 'args = ["dlx", "@upstash/context7-mcp"]\n' - fi - - printf '\n[mcp_servers.github]\n' - printf 'command = "bash"\n' - printf 'args = ["-lc", "%s"]\n' "$(toml_escape "$github_bootstrap")" - - printf '\n[mcp_servers.memory]\n' - printf 'command = "pnpm"\n' - printf 'args = ["dlx", "@modelcontextprotocol/server-memory"]\n' - - printf '\n[mcp_servers.sequential-thinking]\n' - printf 'command = "pnpm"\n' - printf 'args = ["dlx", "@modelcontextprotocol/server-sequential-thinking"]\n' - } >> "$CONFIG_FILE" +log "Merging ECC MCP servers into $CONFIG_FILE (add-only, preserving user config)" +if [[ "$MODE" == "dry-run" ]]; then + node "$MCP_MERGE_SCRIPT" "$CONFIG_FILE" --dry-run $UPDATE_MCP else - log "Skipping MCP config normalization in dry-run mode" + node "$MCP_MERGE_SCRIPT" "$CONFIG_FILE" $UPDATE_MCP fi log "Installing global git safety hooks"