From 0e78ac72b0949fc9f176738c48fd0b991e44e5dd Mon Sep 17 00:00:00 2001 From: nunziati Date: Fri, 10 Apr 2026 12:21:42 +0000 Subject: [PATCH] Implement preserving config merge for opencode. Co-Authored-By: win0na feat(opencode): config-merge.sh with formatting-preserving JSON merge Co-Authored-By: Claude Sonnet 4.6 build(opencode): source config-merge.sh from adapter Co-Authored-By: Claude Sonnet 4.6 feat(install): use oc_config_merge for opencode.json instead of overwrite Source config-merge.sh from install scripts for opencode platform so user keys in opencode.json are preserved on reinstall and update. Fix in-place merge by writing to a temp file before moving to output. Co-Authored-By: Claude Sonnet 4.6 --- adapters/opencode/adapter.sh | 3 + adapters/opencode/config-merge.sh | 75 ++++++++ scripts/launchme.sh | 13 +- scripts/updateme.sh | 13 +- tests/adapters/opencode/config-merge.test.sh | 172 +++++++++++++++++++ 5 files changed, 274 insertions(+), 2 deletions(-) create mode 100755 adapters/opencode/config-merge.sh create mode 100755 tests/adapters/opencode/config-merge.test.sh diff --git a/adapters/opencode/adapter.sh b/adapters/opencode/adapter.sh index 83c9ae4..491e1aa 100755 --- a/adapters/opencode/adapter.sh +++ b/adapters/opencode/adapter.sh @@ -7,6 +7,9 @@ # opencode expects in the user's vault. # ============================================================================= +# shellcheck source=adapters/opencode/config-merge.sh +source "$(dirname "${BASH_SOURCE[0]}")/config-merge.sh" + OC_PLATFORM="opencode" OC_FW_DIR="opencode" OC_DISPATCHER="AGENTS.md" diff --git a/adapters/opencode/config-merge.sh b/adapters/opencode/config-merge.sh new file mode 100755 index 0000000..5fea933 --- /dev/null +++ b/adapters/opencode/config-merge.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# ============================================================================= +# adapters/opencode/config-merge.sh — JSON config merge for opencode.json +# ============================================================================= +# Merges a built opencode.json with an existing one in the vault, preserving +# user keys and formatting. Only the "mcp" key is managed; everything else +# belongs to the user. +# +# Sourced by the opencode adapter. Requires jq. +# ============================================================================= + +# Provide a fallback warn() if scripts/lib.sh has not been sourced yet. +if ! declare -f warn >/dev/null 2>&1; then + warn() { echo " ! $*" >&2; } +fi + +# oc_detect_indent +# Detects the indentation unit used in a JSON file. +# Returns the number of spaces (2 or 4). Defaults to 2. +oc_detect_indent() { + local file="$1" + local indent_str; indent_str="$(awk '/^[[:space:]]+[^[:space:]]/ { match($0, /^[[:space:]]+/); print substr($0, 1, RLENGTH); exit }' "$file")" + if [[ -z "$indent_str" ]]; then + echo "2" + return + fi + case "$indent_str" in + $'\t'*) echo "tab" ;; + " "*) echo "4" ;; + *) echo "2" ;; + esac +} + +# oc_config_merge +# Merges built opencode.json into an existing one: +# - If existing doesn't exist → copy built as-is +# - If existing is malformed → overwrite with built (with warning) +# - Otherwise → merge: our mcp entries overwrite same-name, user keys preserved +oc_config_merge() { + local built="$1" existing="$2" output="$3" + + # Fresh install — no existing file + if [[ ! -f "$existing" ]]; then + cp "$built" "$output" + return 0 + fi + + # Validate existing file is JSON + if ! jq empty "$existing" 2>/dev/null; then + warn "Existing opencode.json is malformed — overwriting with built version" + cp "$built" "$output" + return 0 + fi + + # Detect indentation from existing file + local indent; indent="$(oc_detect_indent "$existing")" + local jq_indent + case "$indent" in + tab) jq_indent="--tab" ;; + 4) jq_indent="--indent 4" ;; + *) jq_indent="--indent 2" ;; + esac + + # Read our managed MCP entries + local our_mcp; our_mcp="$(jq '.mcp // {}' "$built")" + + # Merge: start with existing, overlay our mcp entries. + # Always write to a temp file first to support in-place merges (output == existing). + local tmp; tmp="$(mktemp)" + # shellcheck disable=SC2086 + jq $jq_indent --argjson our_mcp "$our_mcp" ' + .mcp = ((.mcp // {}) + $our_mcp) + ' "$existing" > "$tmp" + mv "$tmp" "$output" +} diff --git a/scripts/launchme.sh b/scripts/launchme.sh index 279bf63..a09f8db 100755 --- a/scripts/launchme.sh +++ b/scripts/launchme.sh @@ -129,6 +129,12 @@ case "$PLATFORM" in esac PLATFORM_VAULT_DIR="$VAULT_COMPONENTS_DIR" +# Load opencode-specific helpers when building for opencode +if [[ "$PLATFORM" == "opencode" ]]; then + # shellcheck source=adapters/opencode/config-merge.sh + source "$REPO_DIR/adapters/opencode/config-merge.sh" +fi + # ── Migrate legacy manifests (if any) ──────────────────────────────────────── manifest_migrate @@ -213,7 +219,12 @@ install_dispatcher "$DISPATCHER_SRC" "$DISPATCHER_DST" # ── MCP / opencode.json ─────────────────────────────────────────────────────── if [[ -f "$MCP_SRC" ]]; then - copy_if_changed "$MCP_SRC" "$MCP_DST" + if [[ "$PLATFORM" == "opencode" && -f "$MCP_DST" ]]; then + oc_config_merge "$MCP_SRC" "$MCP_DST" "$MCP_DST" + info "Merged opencode.json (user config preserved)" + else + copy_if_changed "$MCP_SRC" "$MCP_DST" + fi fi # ── Done ────────────────────────────────────────────────────────────────────── diff --git a/scripts/updateme.sh b/scripts/updateme.sh index 51c51f8..2f780f5 100755 --- a/scripts/updateme.sh +++ b/scripts/updateme.sh @@ -106,6 +106,12 @@ case "$PLATFORM" in esac PLATFORM_VAULT_DIR="$VAULT_COMPONENTS_DIR" +# Load opencode-specific helpers when building for opencode +if [[ "$PLATFORM" == "opencode" ]]; then + # shellcheck source=adapters/opencode/config-merge.sh + source "$REPO_DIR/adapters/opencode/config-merge.sh" +fi + # ── Migrate legacy manifests (if any) ──────────────────────────────────────── manifest_migrate @@ -142,7 +148,12 @@ CLAUDE_MD_CHANGED=$_LAST_CHANGED # ── MCP / opencode.json ─────────────────────────────────────────────────────── if [[ -f "$MCP_SRC" ]]; then - copy_if_changed "$MCP_SRC" "$MCP_DST" + if [[ "$PLATFORM" == "opencode" && -f "$MCP_DST" ]]; then + oc_config_merge "$MCP_SRC" "$MCP_DST" "$MCP_DST" + info "Merged opencode.json (user config preserved)" + else + copy_if_changed "$MCP_SRC" "$MCP_DST" + fi fi # ── Summary ─────────────────────────────────────────────────────────────────── diff --git a/tests/adapters/opencode/config-merge.test.sh b/tests/adapters/opencode/config-merge.test.sh new file mode 100755 index 0000000..e4eab2d --- /dev/null +++ b/tests/adapters/opencode/config-merge.test.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Tests for adapters/opencode/config-merge.sh +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +source "$ROOT/adapters/lib.sh" +source "$ROOT/adapters/opencode/config-merge.sh" + +test_oc_merge_fresh_install() { + local built; built="$(mktemp)" + local existing; existing="$(mktemp)" + local output; output="$(mktemp)" + rm "$existing" + cat > "$built" <<'HEREDOC' +{ + "mcp": { + "Gmail": { + "type": "remote", + "url": "https://gmail.mcp.claude.com/mcp" + } + } +} +HEREDOC + oc_config_merge "$built" "$existing" "$output" + local result=0 + [[ -f "$output" ]] || { echo "output not created"; result=1; } + jq -e '.mcp.Gmail.url' "$output" >/dev/null || { echo "Gmail not in output"; result=1; } + rm -f "$built" "$output" + return $result +} + +test_oc_merge_preserves_user_keys() { + local built; built="$(mktemp)" + local existing; existing="$(mktemp)" + local output; output="$(mktemp)" + cat > "$built" <<'HEREDOC' +{ + "mcp": { + "Gmail": { + "type": "remote", + "url": "https://gmail.mcp.claude.com/mcp" + } + } +} +HEREDOC + cat > "$existing" <<'HEREDOC' +{ + "model": "anthropic/claude-sonnet-4-5", + "small_model": "anthropic/claude-haiku-4-5", + "mcp": { + "MyCustomServer": { + "type": "local", + "command": "my-server" + } + } +} +HEREDOC + oc_config_merge "$built" "$existing" "$output" + local result=0 + jq -e '.mcp.Gmail.url' "$output" >/dev/null || { echo "Gmail missing"; result=1; } + jq -e '.model == "anthropic/claude-sonnet-4-5"' "$output" >/dev/null || { echo "model key lost"; result=1; } + jq -e '.small_model == "anthropic/claude-haiku-4-5"' "$output" >/dev/null || { echo "small_model key lost"; result=1; } + jq -e '.mcp.MyCustomServer.command == "my-server"' "$output" >/dev/null || { echo "user MCP server lost"; result=1; } + rm -f "$built" "$existing" "$output" + return $result +} + +test_oc_merge_our_mcp_overwrites_same_name() { + local built; built="$(mktemp)" + local existing; existing="$(mktemp)" + local output; output="$(mktemp)" + cat > "$built" <<'HEREDOC' +{ + "mcp": { + "Gmail": { + "type": "remote", + "url": "https://gmail.mcp.claude.com/mcp" + } + } +} +HEREDOC + cat > "$existing" <<'HEREDOC' +{ + "mcp": { + "Gmail": { + "type": "remote", + "url": "https://old-url.example.com" + } + } +} +HEREDOC + oc_config_merge "$built" "$existing" "$output" + local result=0 + local url; url="$(jq -r '.mcp.Gmail.url' "$output")" + [[ "$url" == "https://gmail.mcp.claude.com/mcp" ]] || { echo "our url should win: got $url"; result=1; } + rm -f "$built" "$existing" "$output" + return $result +} + +test_oc_merge_detects_indentation() { + local built; built="$(mktemp)" + local existing; existing="$(mktemp)" + local output; output="$(mktemp)" + cat > "$built" <<'HEREDOC' +{ + "mcp": { + "Gmail": { + "type": "remote", + "url": "https://gmail.mcp.claude.com/mcp" + } + } +} +HEREDOC + cat > "$existing" <<'HEREDOC' +{ + "model": "anthropic/claude-sonnet-4-5", + "mcp": {} +} +HEREDOC + oc_config_merge "$built" "$existing" "$output" + local result=0 + grep -q '^ "model"' "$output" || { echo "expected 4-space indent"; cat "$output"; result=1; } + rm -f "$built" "$existing" "$output" + return $result +} + +test_oc_merge_malformed_existing_falls_back() { + local built; built="$(mktemp)" + local existing; existing="$(mktemp)" + local output; output="$(mktemp)" + cat > "$built" <<'HEREDOC' +{ + "mcp": { + "Gmail": { + "type": "remote", + "url": "https://gmail.mcp.claude.com/mcp" + } + } +} +HEREDOC + echo "this is not json" > "$existing" + oc_config_merge "$built" "$existing" "$output" 2>/dev/null + local result=0 + jq -e '.mcp.Gmail.url' "$output" >/dev/null || { echo "fallback failed"; result=1; } + rm -f "$built" "$existing" "$output" + return $result +} + +test_oc_merge_no_mcp_in_existing() { + local built; built="$(mktemp)" + local existing; existing="$(mktemp)" + local output; output="$(mktemp)" + cat > "$built" <<'HEREDOC' +{ + "mcp": { + "Gmail": { + "type": "remote", + "url": "https://gmail.mcp.claude.com/mcp" + } + } +} +HEREDOC + cat > "$existing" <<'HEREDOC' +{ + "model": "anthropic/claude-sonnet-4-5" +} +HEREDOC + oc_config_merge "$built" "$existing" "$output" + local result=0 + jq -e '.model == "anthropic/claude-sonnet-4-5"' "$output" >/dev/null || { echo "model key lost"; result=1; } + jq -e '.mcp.Gmail.url' "$output" >/dev/null || { echo "Gmail not added"; result=1; } + rm -f "$built" "$existing" "$output" + return $result +}