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 @@ -58,6 +58,14 @@ jobs:
- name: Run tests/cli-channel-perms.sh
run: ./tests/cli-channel-perms.sh

ai-gateway:
name: WP AI Gateway opt-in regression (#173)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests/ai-gateway.sh
run: ./tests/ai-gateway.sh

kimaki-agent-fallback:
name: Kimaki native agent fallback regression
runs-on: ubuntu-latest
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ operator-entrypoints/wp-coding-agents-setup/setup.md
| `--chat <bridge>` | Chat bridge: `kimaki`, `cc-connect`, or `telegram`. |
| `--no-chat` | Skip chat bridge setup. |
| `--with-homeboy` | Enable optional Homeboy project/lab integration when available. |
| `--with-ai-gateway` | Enable optional [WP AI Gateway](https://github.com/Automattic/wp-ai-gateway) setup for OpenCode runtimes. |
| `--ai-gateway-provider <id>` | WordPress AI Client backend provider for the gateway route (default: `openai`). |
| `--ai-gateway-model <id>` | Backend model for the gateway route (default: `gpt-4o-mini`). |
| `--ai-gateway-opencode-model <id>` | OpenCode-facing gateway model ID (default: `site-default`). |
| `--rotate-ai-gateway-token` | Mint a replacement gateway token instead of reusing `.opencode/wp-ai-gateway.env`. |
| `--multisite` | Configure WordPress multisite. |
| `--subdomain` | Use subdomain multisite. |
| `--no-skills` | Skip installing bundled agent skills. |
Expand All @@ -169,6 +174,13 @@ Run `./setup.sh --help` for the complete setup surface.

OpenCode uses `opencode.json` with Data Machine-composed instruction files. Kimaki is the default chat bridge for OpenCode when chat is enabled.

Pass `--with-ai-gateway` to opt OpenCode into this site's [WP AI Gateway](https://github.com/Automattic/wp-ai-gateway) endpoint. Setup installs the gateway/provider stack, configures the backend route via WP-CLI, mints (or reuses) a gateway token, and writes an OpenAI-compatible `provider.wp-ai-gateway` entry so clients receive only the gateway token while upstream credentials stay in WordPress. Native OpenCode auth is untouched unless gateway mode is opted in.

```bash
EXISTING_WP=~/Studio/my-site ./setup.sh --local --runtime opencode \
--with-ai-gateway --ai-gateway-provider openai --ai-gateway-model gpt-4o-mini
```

### Claude Code

Claude Code uses `CLAUDE.md` with generated `@` includes. A SessionStart hook refreshes the Data Machine memory includes for each session.
Expand Down
52 changes: 29 additions & 23 deletions bridges/_dispatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,35 @@ adopt_service_identity_from_units() {
return 0
}

# Redact secret-looking values before printing generated unit/plist diffs.
# Dry-run output is operator-facing and often pasted into chats or PRs.
_redact_secret_diff() {
awk '
/<key>[^<]*(TOKEN|SECRET|PASSWORD|API_KEY|REFRESH_TOKEN)[^<]*<\/key>/ {
print
redact_next_string = 1
next
}
redact_next_string && /<string>.*<\/string>/ {
sub(/<string>.*<\/string>/, "<string><redacted></string>")
print
redact_next_string = 0
next
}
/^[-+ ]Environment=[^=]*(TOKEN|SECRET|PASSWORD|API_KEY|REFRESH_TOKEN)=/ {
sub(/=.*/, "=<redacted>")
print
next
}
/^[-+ ](OPENAI_API_KEY|KIMAKI_BOT_TOKEN|TELEGRAM_BOT_TOKEN|CC_CONNECT_TOKEN)=/ {
sub(/=.*/, "=<redacted>")
print
next
}
{ print }
'
}

# _smart_update_systemd_unit <unit_file> <new_unit> [<label>]
#
# Diff + write + daemon-reload a single systemd unit. Records the change in
Expand Down Expand Up @@ -326,29 +355,6 @@ _smart_update_systemd_unit() {
UPDATED_ITEMS+=("$label (daemon-reloaded, not restarted)")
}

_redact_secret_diff() {
awk '
/Environment=.*TOKEN=/ {
sub(/=.*/, "=<redacted>")
print
redact_next_string = 0
next
}
/<key>.*TOKEN/ {
print
redact_next_string = 1
next
}
redact_next_string && /<string>.*<\/string>/ {
sub(/<string>.*<\/string>/, "<string><redacted></string>")
print
redact_next_string = 0
next
}
{ print; redact_next_string = 0 }
'
}

# _plist_string_after_key <plist_path> <key>
#
# Extract the <string>VALUE</string> immediately after a given <key>NAME</key>
Expand Down
33 changes: 32 additions & 1 deletion bridges/kimaki.sh
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,10 @@ Environment=DATAMACHINE_AGENT_SLUG=$AGENT_SLUG"
ENV_BLOCK="$ENV_BLOCK
Environment=KIMAKI_BOT_TOKEN=$KIMAKI_BOT_TOKEN"
fi
if declare -F ai_gateway_enabled_for_opencode >/dev/null && ai_gateway_enabled_for_opencode; then
ENV_BLOCK="$ENV_BLOCK
EnvironmentFile=-$(ai_gateway_env_file)"
fi

write_file "/etc/systemd/system/kimaki.service" \
"$(bridge_render_systemd kimaki.service "$ENV_BLOCK")"
Expand Down Expand Up @@ -720,6 +724,13 @@ Environment=DATAMACHINE_AGENT_SLUG=$AGENT_SLUG"
local MERGED_ENV
MERGED_ENV=$(_merge_systemd_env_lines "$CURRENT_ENV" "$TEMPLATE_ENV")
MERGED_ENV=$(_preserve_systemd_umask "$UNIT_FILE" "$MERGED_ENV")
if declare -F ai_gateway_enabled_for_opencode >/dev/null && ai_gateway_enabled_for_opencode; then
local gateway_env_line="EnvironmentFile=-$(ai_gateway_env_file)"
if ! grep -qF "$gateway_env_line" "$UNIT_FILE" 2>/dev/null; then
MERGED_ENV="$MERGED_ENV
$gateway_env_line"
fi
fi

local NEW_UNIT
NEW_UNIT=$(bridge_render_systemd kimaki.service "$MERGED_ENV")
Expand Down Expand Up @@ -866,7 +877,7 @@ $skill_filter_plist_args
<key>DATAMACHINE_AGENT_SLUG</key>
<string>$AGENT_SLUG</string>"; fi)$(if [ -n "${KIMAKI_BOT_TOKEN:-}" ]; then echo "
<key>KIMAKI_BOT_TOKEN</key>
<string>$KIMAKI_BOT_TOKEN</string>"; fi)
<string>$KIMAKI_BOT_TOKEN</string>"; fi)$(_kimaki_ai_gateway_launchd_env_xml)
</dict>
</dict>
</plist>
Expand Down Expand Up @@ -894,6 +905,26 @@ _kimaki_skill_filter_mode() {
printf '%s\n' "disable"
}

_kimaki_ai_gateway_launchd_env_xml() {
declare -F ai_gateway_enabled_for_opencode >/dev/null || return 0
ai_gateway_enabled_for_opencode || return 0
declare -F ai_gateway_read_env_value >/dev/null || return 0

local env_file base_url api_key
env_file="$(ai_gateway_env_file)"
base_url="$(ai_gateway_read_env_value OPENAI_BASE_URL "$env_file")"
api_key="$(ai_gateway_read_env_value OPENAI_API_KEY "$env_file")"
[ -n "$base_url" ] || base_url="$(ai_gateway_base_url)"

echo "
<key>OPENAI_BASE_URL</key>
<string>$base_url</string>"
if [ -n "$api_key" ]; then
echo " <key>OPENAI_API_KEY</key>
<string>$api_key</string>"
fi
}

_kimaki_skill_filter_source() {
if [ "$(_kimaki_skill_filter_mode)" = "enable" ]; then
if [ -n "${KIMAKI_SKILL_ENABLES_FILE:-}" ]; then
Expand Down
217 changes: 217 additions & 0 deletions lib/ai-gateway.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
#!/bin/bash
# Optional WP AI Gateway integration for OpenCode runtimes.

AI_GATEWAY_PROVIDER_ID="wp-ai-gateway"
AI_GATEWAY_MODEL_ID="${AI_GATEWAY_MODEL_ID:-site-default}"
AI_GATEWAY_ROUTE_PROVIDER="${AI_GATEWAY_ROUTE_PROVIDER:-openai}"
AI_GATEWAY_ROUTE_MODEL="${AI_GATEWAY_ROUTE_MODEL:-gpt-4o-mini}"
AI_GATEWAY_ENV_FILE="${AI_GATEWAY_ENV_FILE:-}"

ai_gateway_enabled_for_opencode() {
[ "${WITH_AI_GATEWAY:-false}" = true ] || return 1

local runtime
for runtime in "${DETECTED_RUNTIMES[@]:-}"; do
[ "$runtime" = "opencode" ] && return 0
done

[ "${RUNTIME:-}" = "opencode" ]
}

ai_gateway_validate_topology() {
ai_gateway_enabled_for_opencode || return 0

local route_provider route_model model_provider
route_provider="$(printf '%s' "$AI_GATEWAY_ROUTE_PROVIDER" | tr '[:upper:]' '[:lower:]')"
route_model="$AI_GATEWAY_ROUTE_MODEL"
model_provider=""
case "$route_model" in
*:*) model_provider="${route_model%%:*}" ;;
esac
model_provider="$(printf '%s' "$model_provider" | tr '[:upper:]' '[:lower:]')"

case "$route_provider" in
wp-ai-gateway|ai-gateway|opencode)
error "Refusing recursive WP AI Gateway topology: OpenCode gateway route provider '$AI_GATEWAY_ROUTE_PROVIDER' would route back toward OpenCode/gateway mode. Choose a backend provider such as openai."
;;
esac

case "$model_provider" in
wp-ai-gateway|ai-gateway|opencode)
error "Refusing recursive WP AI Gateway topology: OpenCode gateway route model '$AI_GATEWAY_ROUTE_MODEL' is provider-qualified back toward OpenCode/gateway mode. Choose a backend model for $AI_GATEWAY_ROUTE_PROVIDER."
;;
esac
}

ai_gateway_base_url() {
local site_url="${AI_GATEWAY_SITE_URL:-}"
if [ -z "$site_url" ]; then
if [ -n "${SITE_DOMAIN:-}" ]; then
case "$SITE_DOMAIN" in
http://*|https://*) site_url="$SITE_DOMAIN" ;;
*) site_url="https://$SITE_DOMAIN" ;;
esac
else
site_url="https://example.com"
fi
fi

printf '%s/wp-json/wp-ai-gateway/v1' "${site_url%/}"
}

ai_gateway_env_file() {
if [ -n "$AI_GATEWAY_ENV_FILE" ]; then
printf '%s' "$AI_GATEWAY_ENV_FILE"
return
fi
printf '%s/.opencode/wp-ai-gateway.env' "$SITE_PATH"
}

ai_gateway_install_stack() {
ai_gateway_enabled_for_opencode || return 0

log "Phase 6.5: Installing WP AI Gateway provider stack..."
install_plugin ai-provider-for-openai https://github.com/WordPress/ai-provider-for-openai.git
install_plugin wp-ai-gateway https://github.com/Automattic/wp-ai-gateway.git
}

ai_gateway_configure_wordpress() {
ai_gateway_enabled_for_opencode || return 0

log "Configuring WP AI Gateway route: $AI_GATEWAY_ROUTE_PROVIDER / $AI_GATEWAY_ROUTE_MODEL"
if [ "$DRY_RUN" = true ]; then
echo -e "${BLUE}[dry-run]${NC} $WP_CMD ai-gateway configure $AI_GATEWAY_ROUTE_PROVIDER $AI_GATEWAY_ROUTE_MODEL --path=$SITE_PATH $WP_ROOT_FLAG"
return 0
fi

wp_cmd ai-gateway configure "$AI_GATEWAY_ROUTE_PROVIDER" "$AI_GATEWAY_ROUTE_MODEL"
}

ai_gateway_read_env_value() {
local key="$1" file="$2" line
[ -f "$file" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"$key"=*) printf '%s' "${line#*=}"; return 0 ;;
export\ "$key"=*) printf '%s' "${line#export $key=}"; return 0 ;;
esac
done < "$file"
}

ai_gateway_mint_token() {
local token_output token
token_output=$(wp_cmd ai-gateway token 2>&1)
token=$(printf '%s\n' "$token_output" | grep '^wpag_' | tail -1 || true)
if [ -z "$token" ]; then
warn "Could not parse WP AI Gateway token output; leaving OpenCode env unchanged"
return 1
fi
printf '%s' "$token"
}

ai_gateway_write_env() {
ai_gateway_enabled_for_opencode || return 0

local env_file token base_url existing_token
env_file="$(ai_gateway_env_file)"
base_url="$(ai_gateway_base_url)"
existing_token="$(ai_gateway_read_env_value OPENAI_API_KEY "$env_file")"

if [ -n "$existing_token" ] && [ "${ROTATE_AI_GATEWAY_TOKEN:-false}" != true ]; then
log "Reusing existing WP AI Gateway token reference at $env_file"
token="$existing_token"
elif [ "$DRY_RUN" = true ]; then
echo -e "${BLUE}[dry-run]${NC} Would mint a WP AI Gateway token and write OPENAI_API_KEY to $env_file (redacted)"
echo -e "${BLUE}[dry-run]${NC} Would write OPENAI_BASE_URL=$base_url to $env_file"
return 0
else
token="$(ai_gateway_mint_token)" || return 0
fi

if [ "$DRY_RUN" = true ]; then
return 0
fi

mkdir -p "$(dirname "$env_file")"
{
printf 'OPENAI_BASE_URL=%s\n' "$base_url"
printf 'OPENAI_API_KEY=%s\n' "$token"
} > "$env_file"
chmod 600 "$env_file"
if [ "${LOCAL_MODE:-false}" = false ] && [ -n "${SERVICE_USER:-}" ]; then
chown "$SERVICE_USER:$SERVICE_USER" "$env_file" 2>/dev/null || true
fi
UPDATED_ITEMS+=("WP AI Gateway OpenCode env ($env_file)")
}

ai_gateway_configure_opencode() {
ai_gateway_enabled_for_opencode || return 0

local config_file env_file base_url
config_file="$SITE_PATH/opencode.json"
env_file="$(ai_gateway_env_file)"
base_url="$(ai_gateway_base_url)"

if [ "$DRY_RUN" = true ]; then
echo -e "${BLUE}[dry-run]${NC} Would merge WP AI Gateway provider into $config_file"
echo -e "${BLUE}[dry-run]${NC} Provider: $AI_GATEWAY_PROVIDER_ID/$AI_GATEWAY_MODEL_ID via OPENAI_BASE_URL=$base_url and OPENAI_API_KEY=<redacted>"
return 0
fi

if [ ! -f "$config_file" ]; then
warn "OpenCode config not found at $config_file — skipping WP AI Gateway provider merge"
return 0
fi

python3 - "$config_file" "$AI_GATEWAY_PROVIDER_ID" "$AI_GATEWAY_MODEL_ID" <<'PY'
import json
import sys
from pathlib import Path

path = Path(sys.argv[1])
provider_id = sys.argv[2]
model_id = sys.argv[3]

data = json.loads(path.read_text(encoding="utf-8"))
provider = data.setdefault("provider", {}).setdefault(provider_id, {})
provider.setdefault("name", "WP AI Gateway")
provider.setdefault("npm", "@ai-sdk/openai-compatible")
provider.setdefault("env", ["OPENAI_API_KEY"])
provider.setdefault("options", {})
provider["options"].setdefault("baseURL", "${OPENAI_BASE_URL}")
provider["options"].setdefault("name", "wp-ai-gateway")
models = provider.setdefault("models", {})
model = models.setdefault(model_id, {})
model.setdefault("name", "WP AI Gateway site default")
model.setdefault("id", model_id)
model.setdefault("tool_call", True)
model.setdefault("temperature", True)
model.setdefault("limit", {"context": 128000, "output": 8192})
model.setdefault("cost", {"input": 0, "output": 0})

if not data.get("model"):
data["model"] = f"{provider_id}/{model_id}"

path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
PY

UPDATED_ITEMS+=("opencode.json WP AI Gateway provider")
log "Merged WP AI Gateway provider into $config_file"
log "OpenCode env file: $env_file"
}

setup_ai_gateway() {
ai_gateway_enabled_for_opencode || return 0
ai_gateway_validate_topology
ai_gateway_install_stack
ai_gateway_configure_wordpress
ai_gateway_write_env
}

upgrade_ai_gateway() {
ai_gateway_enabled_for_opencode || return 0
ai_gateway_validate_topology
ai_gateway_install_stack
ai_gateway_configure_wordpress
ai_gateway_write_env
}
Loading
Loading