Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ jobs:
- uses: actions/checkout@v4
- name: Run tests/bridge-render.sh
run: ./tests/bridge-render.sh

opencode-wrapper:
name: opencode wrapper regression
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests/opencode-wrapper.sh
run: ./tests/opencode-wrapper.sh
67 changes: 50 additions & 17 deletions runtimes/opencode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,39 +33,53 @@ _install_opencode_wrapper() {
local OPENCODE_BIN
OPENCODE_BIN=$(command -v opencode 2>/dev/null || echo "/usr/bin/opencode")

# Don't wrap if opencode is already a wrapper script (re-runs)
if head -1 "$OPENCODE_BIN" 2>/dev/null | grep -q "bash"; then
log "OpenCode wrapper already installed — skipping"
return
fi

# Find the real opencode binary (after npm install, it's in node_modules)
local REAL_BIN
REAL_BIN=$(readlink -f "$OPENCODE_BIN" 2>/dev/null || echo "$OPENCODE_BIN")
# If the resolved path is still a wrapper, dig deeper
# If the resolved path is still a wrapper, dig deeper. Existing installs may
# have old wp-coding-agents wrappers at /usr/local/bin/opencode; parse their
# exec target before falling back to the npm global package path.
if head -1 "$REAL_BIN" 2>/dev/null | grep -q "bash"; then
REAL_BIN="/usr/lib/node_modules/opencode-ai/bin/opencode"
local WRAPPED_REAL_BIN
WRAPPED_REAL_BIN=""
while IFS= read -r line; do
case "$line" in
exec\ *opencode*|exec\ /*opencode*)
set -- $line
WRAPPED_REAL_BIN="${2#\'}"
WRAPPED_REAL_BIN="${WRAPPED_REAL_BIN%\'}"
WRAPPED_REAL_BIN="${WRAPPED_REAL_BIN#\"}"
WRAPPED_REAL_BIN="${WRAPPED_REAL_BIN%\"}"
;;
esac
done < "$REAL_BIN"
if [ -n "$WRAPPED_REAL_BIN" ] && [ -f "$WRAPPED_REAL_BIN" ]; then
REAL_BIN="$WRAPPED_REAL_BIN"
else
REAL_BIN="/usr/lib/node_modules/opencode-ai/bin/opencode"
fi
fi

if [ ! -f "$REAL_BIN" ]; then
warn "Could not find real opencode binary — skipping wrapper install"
return
fi

log "Installing OpenCode credential sync wrapper..."

local WRAPPER_CONTENT='#!/usr/bin/env bash
local WRAPPER_CONTENT
WRAPPER_CONTENT=$(cat <<'EOF'
#!/usr/bin/env bash
# wp-coding-agents-opencode-wrapper-v2
set -euo pipefail

# Syncs Anthropic credentials from Kimaki'\''s account store into the format
# Syncs Anthropic credentials from Kimaki's account store into the format
# opencode-claude-auth reads (~/.claude/.credentials.json). Kimaki manages
# OAuth token refresh — this wrapper forwards fresh tokens on each invocation.

KIMAKI_ACCOUNTS="${HOME}/.local/share/opencode/anthropic-oauth-accounts.json"
CLAUDE_CREDENTIALS="${HOME}/.claude/.credentials.json"

if [[ -f "$KIMAKI_ACCOUNTS" ]] && command -v node >/dev/null 2>&1; then
node -e '"'"'
node -e '
const fs = require("fs");
const path = require("path");
try {
Expand All @@ -91,7 +105,7 @@ if [[ -f "$KIMAKI_ACCOUNTS" ]] && command -v node >/dev/null 2>&1; then
fs.writeFileSync(claudePath, JSON.stringify(creds, null, 2), { mode: 0o600 });
}
} catch {}
'"'"' 2>/dev/null || true
' 2>/dev/null || true
fi

# Legacy sync: claude creds → opencode auth.json (fallback for built-in auth)
Expand All @@ -101,15 +115,34 @@ if [[ -f "$CLAUDE_CREDENTIALS" ]] && command -v jq >/dev/null 2>&1; then
jq "{anthropic:{type:\"oauth\",refresh:(.claudeAiOauth.refreshToken//error(\"missing\")),access:(.claudeAiOauth.accessToken//error(\"missing\")),expires:(.claudeAiOauth.expiresAt//error(\"missing\"))}}" "$CLAUDE_CREDENTIALS" > "${AUTH_DST}.tmp" 2>/dev/null && mv "${AUTH_DST}.tmp" "$AUTH_DST"
fi

exec '"${REAL_BIN}"' "$@"
'
exec "__REAL_BIN__" "$@"
EOF
)
WRAPPER_CONTENT=${WRAPPER_CONTENT/__REAL_BIN__/$REAL_BIN}

if [ -f "$OPENCODE_BIN" ] && [ "$(cat "$OPENCODE_BIN" 2>/dev/null)" = "$WRAPPER_CONTENT" ]; then
log "OpenCode wrapper already at current version — skipping"
return
fi

if [ "$DRY_RUN" = true ]; then
echo -e "${BLUE}[dry-run]${NC} Would install OpenCode wrapper at $OPENCODE_BIN"
if head -1 "$OPENCODE_BIN" 2>/dev/null | grep -q "bash"; then
echo -e "${BLUE}[dry-run]${NC} Would replace OpenCode wrapper at $OPENCODE_BIN"
else
echo -e "${BLUE}[dry-run]${NC} Would install OpenCode wrapper at $OPENCODE_BIN"
fi
else
if head -1 "$OPENCODE_BIN" 2>/dev/null | grep -q "bash"; then
local BACKUP_PATH="${OPENCODE_BIN}.bak.$(date +%Y%m%d%H%M%S)"
cp "$OPENCODE_BIN" "$BACKUP_PATH"
log "Replacing outdated OpenCode wrapper at $OPENCODE_BIN (backup: $BACKUP_PATH)"
else
log "Installing OpenCode credential sync wrapper..."
fi
echo "$WRAPPER_CONTENT" > "$OPENCODE_BIN"
chmod +x "$OPENCODE_BIN"
log "Installed credential sync wrapper at $OPENCODE_BIN → $REAL_BIN"
UPDATED_ITEMS+=("opencode credential wrapper")
fi
}

Expand Down
125 changes: 125 additions & 0 deletions tests/opencode-wrapper.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/bin/bash
# tests/opencode-wrapper.sh — regression tests for the Kimaki OpenCode wrapper.
set -eu

SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$SCRIPT_DIR"

PASS=0
FAIL=0

log() { :; }
warn() { printf 'WARN: %s\n' "$*" >&2; }

assert_file_contains() {
local label="$1" file="$2" needle="$3"
if grep -qF "$needle" "$file"; then
echo " ok $label"
PASS=$((PASS+1))
else
echo " FAIL $label"
echo " expected $file to contain: $needle"
FAIL=$((FAIL+1))
fi
}

assert_eq() {
local label="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
echo " ok $label"
PASS=$((PASS+1))
else
echo " FAIL $label"
echo " expected: '$expected'"
echo " actual: '$actual'"
FAIL=$((FAIL+1))
fi
}

TMPDIR_TEST="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_TEST"' EXIT

# shellcheck disable=SC1091
source runtimes/opencode.sh
UPDATED_ITEMS=()

make_real_opencode() {
local real_dir="$TMPDIR_TEST/real/bin"
mkdir -p "$real_dir"
cat > "$real_dir/opencode" <<'EOF'
#!/bin/sh
echo real opencode "$@"
EOF
chmod +x "$real_dir/opencode"
printf '%s/opencode' "$real_dir"
}

make_stale_wrapper() {
local wrapper="$1" real_bin="$2"
cat > "$wrapper" <<EOF
#!/usr/bin/env bash
set -euo pipefail
AUTH_SRC="\${HOME}/.claude/.credentials.json"
AUTH_DST="\${HOME}/.local/share/opencode/auth.json"
if [[ -f "\$AUTH_SRC" ]]; then
mkdir -p "\$(dirname "\$AUTH_DST")"
cp "\$AUTH_SRC" "\$AUTH_DST"
fi
exec "$real_bin" "\$@"
EOF
chmod +x "$wrapper"
}

echo "==> stale wrapper replacement"
REAL_BIN="$(make_real_opencode)"
BIN_DIR="$TMPDIR_TEST/bin"
mkdir -p "$BIN_DIR"
STALE_WRAPPER="$BIN_DIR/opencode"
make_stale_wrapper "$STALE_WRAPPER" "$REAL_BIN"

CHAT_BRIDGE=kimaki
LOCAL_MODE=false
DRY_RUN=false
PATH="$BIN_DIR:$PATH"

_install_opencode_wrapper

assert_file_contains "writes current wrapper sentinel" "$STALE_WRAPPER" "# wp-coding-agents-opencode-wrapper-v2"
assert_file_contains "preserves real binary target" "$STALE_WRAPPER" "exec \"$REAL_BIN\""
assert_eq "backs up stale wrapper" "1" "$(ls "$BIN_DIR"/opencode.bak.* 2>/dev/null | wc -l | tr -d ' ')"

_install_opencode_wrapper

assert_eq "current wrapper rerun is idempotent" "1" "$(ls "$BIN_DIR"/opencode.bak.* 2>/dev/null | wc -l | tr -d ' ')"

echo "==> skip gates"
NON_KIMAKI_DIR="$TMPDIR_TEST/non-kimaki/bin"
mkdir -p "$NON_KIMAKI_DIR"
NON_KIMAKI_WRAPPER="$NON_KIMAKI_DIR/opencode"
make_stale_wrapper "$NON_KIMAKI_WRAPPER" "$REAL_BIN"
CHAT_BRIDGE=telegram
LOCAL_MODE=false
PATH="$NON_KIMAKI_DIR:$PATH"
_install_opencode_wrapper
assert_eq "non-kimaki bridge leaves wrapper alone" "0" "$(grep -c 'wp-coding-agents-opencode-wrapper-v2' "$NON_KIMAKI_WRAPPER")"

LOCAL_DIR="$TMPDIR_TEST/local/bin"
mkdir -p "$LOCAL_DIR"
LOCAL_WRAPPER="$LOCAL_DIR/opencode"
make_stale_wrapper "$LOCAL_WRAPPER" "$REAL_BIN"
CHAT_BRIDGE=kimaki
LOCAL_MODE=true
PATH="$LOCAL_DIR:$PATH"
_install_opencode_wrapper
assert_eq "local mode leaves wrapper alone" "0" "$(grep -c 'wp-coding-agents-opencode-wrapper-v2' "$LOCAL_WRAPPER")"

echo "==> upgrade phase guard"
assert_file_contains "upgrade sources opencode runtime for kimaki" upgrade.sh 'source "$SCRIPT_DIR/runtimes/opencode.sh"'
assert_file_contains "upgrade invokes wrapper refresh" upgrade.sh "_install_opencode_wrapper"

echo
if [ "$FAIL" -gt 0 ]; then
echo "FAILED: $FAIL of $((PASS+FAIL)) assertion(s)"
exit 1
fi
echo "OK: $PASS / $PASS assertions passed"
22 changes: 20 additions & 2 deletions upgrade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -626,12 +626,30 @@ update_chat_bridge_launchd() {
reapply_claude_auth_patch() {
_run_filter_active patch || return 0

if [ "$RUNTIME" != "opencode" ] && [ "$CHAT_BRIDGE" != "kimaki" ]; then
log "Phase 7: Skipping (runtime is $RUNTIME and chat bridge is $CHAT_BRIDGE)"
return 0
fi

log "Phase 7: Checking OpenCode auth integration..."

if ! declare -F _install_opencode_wrapper >/dev/null; then
# Kimaki spawns opencode-serve even when the primary editing runtime is not
# OpenCode, so stale wrappers must be repairable from any Kimaki install.
# Source the runtime file for its wrapper helper without running a full
# OpenCode runtime install.
# shellcheck disable=SC1091
source "$SCRIPT_DIR/runtimes/opencode.sh"
fi

_install_opencode_wrapper

if [ "$RUNTIME" != "opencode" ]; then
log "Phase 7: Skipping (runtime is $RUNTIME, not opencode)"
log " Skipping opencode-claude-auth patch (runtime is $RUNTIME)"
return 0
fi

log "Phase 7: Re-applying opencode-claude-auth PascalCase patch..."
log " Re-applying opencode-claude-auth PascalCase patch..."

if [ ! -f "$SCRIPT_DIR/lib/patch-claude-auth.py" ]; then
warn " patch-claude-auth.py not found — skipping"
Expand Down
Loading