Implement preserving config merge for opencode.

Co-Authored-By: win0na <winnie@winneon.moe>

feat(opencode): config-merge.sh with formatting-preserving JSON merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

build(opencode): source config-merge.sh from adapter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

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 <noreply@anthropic.com>
This commit is contained in:
nunziati
2026-04-10 12:21:42 +00:00
parent f76fcca42a
commit 0e78ac72b0
5 changed files with 274 additions and 2 deletions

View File

@@ -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"

View File

@@ -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 <file>
# 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 <built_file> <existing_file> <output_file>
# 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"
}

View File

@@ -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 ──────────────────────────────────────────────────────────────────────

View File

@@ -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 ───────────────────────────────────────────────────────────────────

View File

@@ -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
}