diff --git a/lib/repair-opencode-json.py b/lib/repair-opencode-json.py new file mode 100755 index 0000000..faf8b85 --- /dev/null +++ b/lib/repair-opencode-json.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +repair-opencode-json.py — Detect and optionally repair the `plugin` array in +an existing opencode.json against what the current wp-coding-agents setup +would produce for the detected (RUNTIME, CHAT_BRIDGE, INSTALL_DATA_MACHINE). + +Exit codes: + 0 — no drift; file is already correct + 1 — drift detected (or repair applied if --apply) + 2 — usage / IO error + +Output (stdout): JSON diagnostic object. Examples: + + {"status":"ok","plugins":[...]} + {"status":"drift","missing":[...],"unexpected":[...],"current":[...],"expected":[...]} + {"status":"repaired","before":[...],"after":[...],"backup":"/path/to/backup"} + +CLI usage: + repair-opencode-json.py --file \ + --runtime \ + --chat-bridge \ + --install-dm \ + [--kimaki-plugins-dir ] \ + [--apply] \ + [--backup-suffix ] + +Only --apply writes to disk. Without it, the tool is a pure diagnostic. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +from typing import List + + +def expected_plugins( + runtime: str, + chat_bridge: str, + install_dm: bool, + kimaki_plugins_dir: str, +) -> List[str]: + """Return the `plugin` array wp-coding-agents setup would produce today. + + Mirrors the logic in runtimes/opencode.sh. Keep in sync when that file + changes. Order matters — setup.sh writes them in this order. + """ + plugins: List[str] = [] + + if runtime != "opencode": + # Non-opencode runtimes don't use the opencode.json plugin array. + # Claude Code / Studio Code have their own config. Return empty so + # "drift" comparisons on those runtimes are no-ops. + return plugins + + # opencode-claude-auth: only when kimaki is NOT the chat bridge. + # Kimaki v0.6.0+ ships a built-in AnthropicAuthPlugin that supersedes it; + # loading both causes them to compete for the `anthropic` auth provider. + # See wp-coding-agents#51. + if chat_bridge != "kimaki": + plugins.append("opencode-claude-auth@latest") + + # DM context filter + agent sync: only when DM handles memory via Kimaki. + if install_dm and chat_bridge == "kimaki": + plugins.append(f"{kimaki_plugins_dir}/dm-context-filter.ts") + plugins.append(f"{kimaki_plugins_dir}/dm-agent-sync.ts") + + return plugins + + +def diff_plugins(current: List[str], expected: List[str]) -> dict: + """Compute missing and unexpected entries. + + `missing` = in expected but not current + `unexpected` = in current but not expected (likely to remove) + + We match by exact string equality. Order differences alone are NOT + flagged as drift — opencode loads plugins regardless of array order. + """ + current_set = set(current) + expected_set = set(expected) + return { + "missing": [p for p in expected if p not in current_set], + "unexpected": [p for p in current if p not in expected_set], + } + + +def repair( + data: dict, expected: List[str], preserve_extras: bool = False +) -> List[str]: + """Return the repaired `plugin` array. + + Default behaviour: replace `plugin` with exactly `expected`. This removes + stale entries (like `opencode-claude-auth@latest` on kimaki installs). + + With preserve_extras=True: add missing entries but keep unexpected ones. + Not currently exposed via CLI — here for future use. + """ + if preserve_extras: + current: List[str] = list(data.get("plugin", [])) + for p in expected: + if p not in current: + current.append(p) + return current + return list(expected) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--file", required=True, help="Path to opencode.json") + parser.add_argument( + "--runtime", + required=True, + choices=["opencode", "claude-code", "studio-code"], + ) + parser.add_argument( + "--chat-bridge", + required=True, + choices=["kimaki", "cc-connect", "telegram", "none"], + ) + parser.add_argument( + "--install-dm", + required=True, + choices=["true", "false"], + ) + parser.add_argument( + "--kimaki-plugins-dir", + default="/opt/kimaki-config/plugins", + help="Directory where DM plugins live (VPS default: /opt/kimaki-config/plugins)", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Write repaired config to disk (with .backup. alongside)", + ) + parser.add_argument( + "--backup-suffix", + default="", + help="Suffix for backup file (default: current timestamp)", + ) + args = parser.parse_args() + + if not os.path.isfile(args.file): + print( + json.dumps({"status": "error", "message": f"file not found: {args.file}"}) + ) + return 2 + + try: + with open(args.file, "r", encoding="utf-8") as fh: + data = json.load(fh) + except json.JSONDecodeError as exc: + print( + json.dumps( + {"status": "error", "message": f"invalid JSON: {exc}"} + ) + ) + return 2 + + install_dm = args.install_dm == "true" + expected = expected_plugins( + runtime=args.runtime, + chat_bridge=args.chat_bridge, + install_dm=install_dm, + kimaki_plugins_dir=args.kimaki_plugins_dir.rstrip("/"), + ) + + current: List[str] = list(data.get("plugin", [])) + + # Claude Code / Studio Code: no plugin array concept here. Report ok + # if current is empty or absent; otherwise let user know we skipped. + if args.runtime != "opencode": + print( + json.dumps( + { + "status": "skipped", + "reason": f"runtime {args.runtime} does not use opencode.json plugin array", + "current": current, + } + ) + ) + return 0 + + diff = diff_plugins(current, expected) + has_drift = bool(diff["missing"] or diff["unexpected"]) + + if not has_drift: + print(json.dumps({"status": "ok", "plugins": current})) + return 0 + + if not args.apply: + print( + json.dumps( + { + "status": "drift", + "missing": diff["missing"], + "unexpected": diff["unexpected"], + "current": current, + "expected": expected, + } + ) + ) + return 1 + + # Apply: write backup, update data, write file. + suffix = args.backup_suffix or __import__("datetime").datetime.now().strftime( + "%Y%m%d-%H%M%S" + ) + backup_path = f"{args.file}.backup.{suffix}" + shutil.copy2(args.file, backup_path) + + data["plugin"] = repair(data, expected) + + with open(args.file, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") + + print( + json.dumps( + { + "status": "repaired", + "before": current, + "after": data["plugin"], + "backup": backup_path, + } + ) + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtimes/opencode.sh b/runtimes/opencode.sh index d2b01f5..59c2c99 100644 --- a/runtimes/opencode.sh +++ b/runtimes/opencode.sh @@ -209,8 +209,15 @@ runtime_generate_config() { # opencode-claude-auth: Claude Max/Pro OAuth auth + billing header injection # + system prompt relocation to avoid Anthropic's third-party app detection. - # Safe to always include — no-op when Claude credentials aren't present. - OPENCODE_PLUGINS="${OPENCODE_PLUGINS}\n \"opencode-claude-auth@latest\"," + # + # Skip when CHAT_BRIDGE=kimaki. Kimaki v0.6.0+ ships a built-in + # AnthropicAuthPlugin that handles the same concerns (OAuth, token refresh, + # request/response rewriting, multi-account rotation). Loading both plugins + # causes them to compete for the same `anthropic` auth provider in OpenCode. + # See Extra-Chill/wp-coding-agents#51. + if [ "$CHAT_BRIDGE" != "kimaki" ]; then + OPENCODE_PLUGINS="${OPENCODE_PLUGINS}\n \"opencode-claude-auth@latest\"," + fi # DM context filter + agent sync — only when DM handles memory via Kimaki if [ "$INSTALL_DATA_MACHINE" = true ] && [ "$CHAT_BRIDGE" = "kimaki" ]; then diff --git a/upgrade.sh b/upgrade.sh index aa9e689..554cfa1 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -37,9 +37,15 @@ # ./upgrade.sh --agents-md-only # only regenerate AGENTS.md # ./upgrade.sh --local --wp-path # local install (auto on macOS) # -# Safety: NEVER touches opencode.json, WordPress DB, nginx, SSL, -# ~/.kimaki/ auth state, the DM workspace cloned repos, -# agent memory files, or the running kimaki service. +# Safety: NEVER touches WordPress DB, nginx, SSL, ~/.kimaki/ auth state, +# the DM workspace cloned repos, agent memory files, or the running +# chat-bridge service. +# +# opencode.json is only touched when --repair-opencode-json is passed. +# The repair surgically rewrites the `plugin` array to match what current +# setup would produce for the detected (runtime, chat bridge, DM) combo, +# preserving all other keys. A .backup. is written alongside. Without +# the flag, drift is diagnosed and reported in the summary but not fixed. # set -e @@ -68,6 +74,7 @@ DRY_RUN=false KIMAKI_ONLY=false SKILLS_ONLY=false AGENTS_MD_ONLY=false +REPAIR_OPENCODE_JSON=false SHOW_HELP=false # Defaults setup.sh expects (detect.sh reads these) @@ -91,6 +98,7 @@ while [[ $# -gt 0 ]]; do --kimaki-only) KIMAKI_ONLY=true; shift ;; --skills-only) SKILLS_ONLY=true; shift ;; --agents-md-only) AGENTS_MD_ONLY=true; shift ;; + --repair-opencode-json) REPAIR_OPENCODE_JSON=true; shift ;; --runtime) RUNTIME="$2"; shift 2 ;; --wp-path) EXISTING_WP="$2"; shift 2 ;; --local) LOCAL_MODE=true; RUN_AS_ROOT=false; shift ;; @@ -113,6 +121,11 @@ USAGE: and telegram when they are the detected bridge) ./upgrade.sh --skills-only Only sync agent skills ./upgrade.sh --agents-md-only Only regenerate AGENTS.md + ./upgrade.sh --repair-opencode-json + Detect AND fix drift between opencode.json's + "plugin" array and what current setup would + produce. Writes a .backup. alongside. + Default behaviour: diagnose + warn only. ./upgrade.sh --runtime Force runtime (auto-detected otherwise) ./upgrade.sh --wp-path Override detected WordPress path ./upgrade.sh --local Local mode (no systemd; auto-on on macOS) @@ -125,12 +138,17 @@ KIMAKI PLUGIN INSTALL TARGETS: Local: \$(npm root -g)/kimaki/plugins NEVER TOUCHED: - - opencode.json / CLAUDE.md runtime config + - CLAUDE.md runtime config - WordPress database, nginx, SSL certs - ~/.kimaki/ auth state and OAuth tokens - DM workspace cloned repos - Agent memory files (SOUL.md, MEMORY.md, USER.md, etc.) - - Running kimaki service (never restarted automatically) + - Running chat-bridge service (never restarted automatically) + +OPT-IN TOUCHES: + - opencode.json — only with --repair-opencode-json. Rewrites the + "plugin" array to match current setup output; preserves all other + keys; writes a .backup. alongside. HELP exit 0 fi @@ -219,6 +237,10 @@ echo "" # Track what was touched for the summary UPDATED_ITEMS=() +# Set true when opencode.json is found to have plugin-array drift and the +# --repair-opencode-json flag was NOT passed. Shown loudly in print_summary. +OPENCODE_JSON_DRIFT=false + # ============================================================================ # Helpers # ============================================================================ @@ -332,18 +354,32 @@ _sync_kimaki_config() { log "Phase 2: Syncing /opt/kimaki-config..." fi - # On local, the kimaki npm package must be installed for plugins to land - # somewhere opencode actually loads from. On VPS, /opt/kimaki-config is - # created by setup.sh — refuse to bootstrap it here. - if [ "$LOCAL_MODE" = false ] && [ ! -d "$KIMAKI_CONFIG_DIR" ]; then - warn " $KIMAKI_CONFIG_DIR does not exist — nothing to sync" - return 0 - fi + # Local: the kimaki npm package must be installed for plugins to land + # somewhere opencode actually loads from. Refuse to bootstrap here — the + # user must install kimaki first. if [ "$LOCAL_MODE" = true ] && [ ! -d "$(dirname "$KIMAKI_PLUGINS_DIR")" ]; then warn " Kimaki npm package not found at $(dirname "$KIMAKI_PLUGINS_DIR") — install with 'npm install -g kimaki'" return 0 fi + # VPS: if /opt/kimaki-config is missing, this install predates v0.4.0 (when + # setup.sh started creating it). We're in the kimaki dispatch branch, so + # kimaki IS the detected bridge and kimaki.service IS running — the + # config dir just never got bootstrapped. Create it now from the repo. + # All contents are wp-coding-agents-owned (plugins, post-upgrade.sh, + # kill list); there is no user state to preserve. + if [ "$LOCAL_MODE" = false ] && [ ! -d "$KIMAKI_CONFIG_DIR" ]; then + if [ "$DRY_RUN" = true ]; then + echo -e "${BLUE}[dry-run]${NC} Would bootstrap $KIMAKI_CONFIG_DIR from $SCRIPT_DIR/kimaki/" + else + log " $KIMAKI_CONFIG_DIR missing — bootstrapping from repo (install predates v0.4.0)" + mkdir -p "$KIMAKI_CONFIG_DIR/plugins" + UPDATED_ITEMS+=("bootstrapped $KIMAKI_CONFIG_DIR (install predates v0.4.0)") + # Fall through — the plugin/post-upgrade/kill-list copy logic below + # handles the actual file placement idempotently. + fi + fi + # Backup current state (only if there's something to back up). if [ -d "$KIMAKI_CONFIG_DIR" ]; then if [ "$DRY_RUN" = true ]; then @@ -437,6 +473,132 @@ _sync_kimaki_config() { RESOLVED_KIMAKI_PLUGINS_DIR="$KIMAKI_PLUGINS_DIR" } +# ============================================================================ +# Phase 2b: Detect + optionally repair opencode.json plugin drift +# +# opencode.json is user-owned (model settings, agent prompt files, permissions, +# etc.), so this phase is read-only by default. It compares the file's +# `plugin` array against what current setup would produce for the detected +# (RUNTIME, CHAT_BRIDGE, INSTALL_DATA_MACHINE) combo and surfaces drift. +# +# The most common drift vectors: +# - install predates v0.4.0 (no `plugin` key at all) +# - install predates #51 fix (stale `opencode-claude-auth@latest` on kimaki) +# - new plugins added to setup.sh that the install never got +# +# With --repair-opencode-json, the `plugin` array is surgically rewritten to +# match the expected list. All other keys are preserved. A .backup. is +# written alongside. +# +# Non-opencode runtimes (claude-code, studio-code) are skipped silently — +# they use different config mechanisms. +# ============================================================================ + +check_opencode_json_drift() { + # Only runs for opencode runtime. Silent skip on others. + if [ "$RUNTIME" != "opencode" ]; then + return 0 + fi + + local OPENCODE_JSON_FILE="$SITE_PATH/opencode.json" + if [ ! -f "$OPENCODE_JSON_FILE" ]; then + warn "Phase 2b: $OPENCODE_JSON_FILE not found — skipping drift check" + return 0 + fi + + local HELPER="$SCRIPT_DIR/lib/repair-opencode-json.py" + if [ ! -f "$HELPER" ]; then + warn "Phase 2b: $HELPER not found — skipping drift check" + return 0 + fi + + local BRIDGE_ARG="${CHAT_BRIDGE:-none}" + local DM_ARG="false" + [ "$INSTALL_DATA_MACHINE" = true ] && DM_ARG="true" + + # Kimaki plugins dir — match what _sync_kimaki_config resolved. + local PLUGINS_DIR="${RESOLVED_KIMAKI_PLUGINS_DIR:-/opt/kimaki-config/plugins}" + + if [ "$REPAIR_OPENCODE_JSON" = true ]; then + log "Phase 2b: Repairing opencode.json plugin array..." + if [ "$DRY_RUN" = true ]; then + echo -e "${BLUE}[dry-run]${NC} Would run: python3 $HELPER --file $OPENCODE_JSON_FILE --runtime $RUNTIME --chat-bridge $BRIDGE_ARG --install-dm $DM_ARG --kimaki-plugins-dir $PLUGINS_DIR --apply" + # Still show the diagnostic even in dry-run + local dry_out + dry_out=$(python3 "$HELPER" \ + --file "$OPENCODE_JSON_FILE" \ + --runtime "$RUNTIME" \ + --chat-bridge "$BRIDGE_ARG" \ + --install-dm "$DM_ARG" \ + --kimaki-plugins-dir "$PLUGINS_DIR" 2>&1 || true) + echo "$dry_out" | sed 's/^/ /' + return 0 + fi + + local out rc + out=$(python3 "$HELPER" \ + --file "$OPENCODE_JSON_FILE" \ + --runtime "$RUNTIME" \ + --chat-bridge "$BRIDGE_ARG" \ + --install-dm "$DM_ARG" \ + --kimaki-plugins-dir "$PLUGINS_DIR" \ + --apply \ + --backup-suffix "$TIMESTAMP" 2>&1) && rc=0 || rc=$? + + local status + status=$(echo "$out" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('status','?'))" 2>/dev/null || echo "parse-error") + + case "$status" in + ok) + log " opencode.json plugin array already correct" + ;; + repaired) + log " opencode.json repaired (backup: ${OPENCODE_JSON_FILE}.backup.$TIMESTAMP)" + log " $out" + UPDATED_ITEMS+=("opencode.json plugin array (repaired)") + ;; + skipped) + log " $out" + ;; + *) + warn " repair-opencode-json.py returned status=$status (rc=$rc)" + warn " $out" + ;; + esac + return 0 + fi + + # Diagnostic-only path (default). + local out rc + out=$(python3 "$HELPER" \ + --file "$OPENCODE_JSON_FILE" \ + --runtime "$RUNTIME" \ + --chat-bridge "$BRIDGE_ARG" \ + --install-dm "$DM_ARG" \ + --kimaki-plugins-dir "$PLUGINS_DIR" 2>&1) && rc=0 || rc=$? + + local status + status=$(echo "$out" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('status','?'))" 2>/dev/null || echo "parse-error") + + case "$status" in + ok) + log "Phase 2b: opencode.json plugin array matches current setup" + ;; + drift) + warn "Phase 2b: opencode.json plugin array has drift — re-run with --repair-opencode-json to fix" + warn " $out" + OPENCODE_JSON_DRIFT=true + ;; + skipped) + log "Phase 2b: $out" + ;; + *) + warn "Phase 2b: repair-opencode-json.py returned status=$status (rc=$rc)" + warn " $out" + ;; + esac +} + # ============================================================================ # Phase 3: Sync agent skills (WordPress + Data Machine) # ============================================================================ @@ -810,6 +972,13 @@ print_summary() { done fi + if [ "$OPENCODE_JSON_DRIFT" = true ]; then + echo "" + warn "opencode.json plugin-array drift detected." + warn " Re-run with: ./upgrade.sh --repair-opencode-json" + warn " (Drift is common on installs that predate #51 or v0.4.0.)" + fi + echo "" _print_bridge_restart_hint _print_verify_block @@ -915,6 +1084,7 @@ _print_verify_block() { # ============================================================================ sync_chat_bridge_config +check_opencode_json_drift sync_skills regenerate_agents_md update_chat_bridge_systemd