|
#!/usr/bin/env bash |
|
# ============================================================ |
|
# install-opencode-go-claude.sh |
|
# |
|
# 一键把 VS Code Claude Code 插件接到 OpenCode Go 的 |
|
# deepseek-v4-flash(Anthropic Messages 协议)。 |
|
# |
|
# 用法: |
|
# bash install-opencode-go-claude.sh |
|
# bash install-opencode-go-claude.sh --key 'sk-xxx' |
|
# OPENCODE_GO_API_KEY=sk-xxx bash install-opencode-go-claude.sh |
|
# |
|
# 可选参数: |
|
# --key KEY OpenCode Go API Key |
|
# --model NAME 默认 deepseek-v4-flash |
|
# --base-url URL 默认 https://opencode.ai/zen/go |
|
# --skip-probe 跳过 API 探测 |
|
# --uninstall 尽量撤销本脚本写入的配置 |
|
# -h, --help 帮助 |
|
# ============================================================ |
|
set -euo pipefail |
|
|
|
MODEL="deepseek-v4-flash" |
|
BASE_URL="https://opencode.ai/zen/go" |
|
API_KEY="${OPENCODE_GO_API_KEY:-}" |
|
SKIP_PROBE=0 |
|
UNINSTALL=0 |
|
|
|
R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[1m'; N=$'\033[0m' |
|
info() { printf '%b\n' "${G}$*${N}"; } |
|
warn() { printf '%b\n' "${Y}$*${N}"; } |
|
err() { printf '%b\n' "${R}$*${N}"; } |
|
die() { err "$*"; exit 1; } |
|
|
|
usage() { |
|
sed -n '2,20p' "$0" | sed 's/^# \?//' |
|
exit 0 |
|
} |
|
|
|
while [ $# -gt 0 ]; do |
|
case "$1" in |
|
--key) API_KEY="${2:-}"; shift 2 ;; |
|
--model) MODEL="${2:-}"; shift 2 ;; |
|
--base-url) BASE_URL="${2:-}"; shift 2 ;; |
|
--skip-probe) SKIP_PROBE=1; shift ;; |
|
--uninstall) UNINSTALL=1; shift ;; |
|
-h|--help) usage ;; |
|
*) die "未知参数: $1(用 --help 查看用法)" ;; |
|
esac |
|
done |
|
|
|
# ---------- paths ---------- |
|
HOME_DIR="${HOME}" |
|
WRAPPER_DIR="${HOME_DIR}/.claude/bin" |
|
WRAPPER_PATH="${WRAPPER_DIR}/claude-vscode-wrapper" |
|
CLAUDE_SETTINGS="${HOME_DIR}/.claude/settings.json" |
|
TS="$(date +%Y%m%d-%H%M%S)" |
|
|
|
detect_vscode_settings() { |
|
local candidates=() |
|
case "$(uname -s)" in |
|
Darwin) |
|
candidates+=( |
|
"${HOME_DIR}/Library/Application Support/Code/User/settings.json" |
|
"${HOME_DIR}/Library/Application Support/Code - Insiders/User/settings.json" |
|
"${HOME_DIR}/Library/Application Support/Cursor/User/settings.json" |
|
"${HOME_DIR}/Library/Application Support/VSCodium/User/settings.json" |
|
) |
|
;; |
|
Linux) |
|
candidates+=( |
|
"${HOME_DIR}/.config/Code/User/settings.json" |
|
"${HOME_DIR}/.config/Code - Insiders/User/settings.json" |
|
"${HOME_DIR}/.config/Cursor/User/settings.json" |
|
"${HOME_DIR}/.config/VSCodium/User/settings.json" |
|
) |
|
;; |
|
MINGW*|MSYS*|CYGWIN*|Windows_NT) |
|
if [ -n "${APPDATA:-}" ]; then |
|
candidates+=( |
|
"${APPDATA}/Code/User/settings.json" |
|
"${APPDATA}/Code - Insiders/User/settings.json" |
|
"${APPDATA}/Cursor/User/settings.json" |
|
) |
|
fi |
|
;; |
|
esac |
|
|
|
local p |
|
for p in "${candidates[@]}"; do |
|
if [ -f "$p" ]; then |
|
echo "$p" |
|
return 0 |
|
fi |
|
done |
|
|
|
# default create location |
|
case "$(uname -s)" in |
|
Darwin) echo "${HOME_DIR}/Library/Application Support/Code/User/settings.json" ;; |
|
*) echo "${HOME_DIR}/.config/Code/User/settings.json" ;; |
|
esac |
|
} |
|
|
|
VS_SETTINGS="$(detect_vscode_settings)" |
|
|
|
read_key_hidden() { |
|
local prompt="$1" val="" |
|
if [ -r /dev/tty ]; then |
|
printf '%s' "$prompt" > /dev/tty |
|
# shellcheck disable=SC2162 |
|
IFS= read -r -s val < /dev/tty || true |
|
printf '\n' > /dev/tty |
|
else |
|
printf '%s' "$prompt" |
|
# shellcheck disable=SC2162 |
|
IFS= read -r -s val || true |
|
printf '\n' |
|
fi |
|
printf '%s' "$val" |
|
} |
|
|
|
need_python() { |
|
command -v python3 >/dev/null 2>&1 || die "需要 python3" |
|
} |
|
|
|
backup_file() { |
|
local f="$1" |
|
if [ -f "$f" ]; then |
|
cp -p "$f" "${f}.bak-opencode-go-${TS}" |
|
info "已备份: ${f}.bak-opencode-go-${TS}" |
|
fi |
|
} |
|
|
|
write_wrapper() { |
|
mkdir -p "$WRAPPER_DIR" |
|
cat > "$WRAPPER_PATH" <<'EOF' |
|
#!/bin/bash |
|
# Claude VS Code forces CLAUDE_CODE_ENTRYPOINT=claude-vscode, which ignores |
|
# third-party ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN. Rewrite to sdk-ts so |
|
# OpenCode Go / DeepSeek Anthropic-compatible endpoints work. |
|
export CLAUDE_CODE_ENTRYPOINT=sdk-ts |
|
|
|
# OpenCode Go /messages only accepts x-api-key (from ANTHROPIC_API_KEY) |
|
if [[ -n "${ANTHROPIC_AUTH_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then |
|
export ANTHROPIC_API_KEY="$ANTHROPIC_AUTH_TOKEN" |
|
fi |
|
|
|
exec "$@" |
|
EOF |
|
chmod +x "$WRAPPER_PATH" |
|
info "已写入 wrapper: $WRAPPER_PATH" |
|
} |
|
|
|
probe_api() { |
|
local key="$1" |
|
command -v curl >/dev/null 2>&1 || { warn "未找到 curl,跳过探测"; return 0; } |
|
info "正在探测 ${BASE_URL}/v1/messages + ${MODEL} ..." |
|
local code body |
|
body="$(mktemp)" |
|
code="$( |
|
curl -sS -o "$body" -w '%{http_code}' \ |
|
-H "x-api-key: ${key}" \ |
|
-H 'content-type: application/json' \ |
|
-H 'anthropic-version: 2023-06-01' \ |
|
"${BASE_URL}/v1/messages" \ |
|
-d "{\"model\":\"${MODEL}\",\"max_tokens\":32,\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly: ok\"}]}" \ |
|
--connect-timeout 15 --max-time 60 || true |
|
)" |
|
if [ "$code" = "200" ]; then |
|
info "✅ API 探测成功 (HTTP 200)" |
|
python3 - "$body" <<'PY' 2>/dev/null || true |
|
import json,sys |
|
d=json.load(open(sys.argv[1])) |
|
texts=[c.get("text","") for c in d.get("content",[]) if c.get("type")=="text"] |
|
print("model:", d.get("model"), "| text:", " ".join(texts)[:80]) |
|
PY |
|
else |
|
warn "⚠️ API 探测失败 HTTP ${code}" |
|
head -c 400 "$body" 2>/dev/null; echo |
|
warn "请检查 API Key / 网络 / 模型名。配置仍会写入,你可稍后重试。" |
|
fi |
|
rm -f "$body" |
|
} |
|
|
|
patch_vscode_settings() { |
|
local settings="$1" wrapper="$2" key="$3" model="$4" base="$5" |
|
mkdir -p "$(dirname "$settings")" |
|
if [ ! -f "$settings" ]; then |
|
printf '{\n}\n' > "$settings" |
|
fi |
|
backup_file "$settings" |
|
|
|
python3 - "$settings" "$wrapper" "$key" "$model" "$base" <<'PY' |
|
import json, re, sys |
|
from pathlib import Path |
|
|
|
path, wrapper, key, model, base = sys.argv[1:6] |
|
raw = Path(path).read_text(encoding="utf-8") |
|
|
|
def strip_jsonc(s: str) -> str: |
|
out = [] |
|
i, n = 0, len(s) |
|
in_str = esc = False |
|
while i < n: |
|
c = s[i] |
|
if in_str: |
|
out.append(c) |
|
if esc: |
|
esc = False |
|
elif c == "\\": |
|
esc = True |
|
elif c == '"': |
|
in_str = False |
|
i += 1 |
|
continue |
|
if c == '"': |
|
in_str = True |
|
out.append(c) |
|
i += 1 |
|
continue |
|
if c == "/" and i + 1 < n and s[i + 1] == "/": |
|
while i < n and s[i] != "\n": |
|
i += 1 |
|
continue |
|
if c == "/" and i + 1 < n and s[i + 1] == "*": |
|
i += 2 |
|
while i + 1 < n and not (s[i] == "*" and s[i + 1] == "/"): |
|
i += 1 |
|
i += 2 |
|
continue |
|
out.append(c) |
|
i += 1 |
|
s2 = "".join(out) |
|
s2 = re.sub(r",\s*([}\]])", r"\1", s2) |
|
return s2 |
|
|
|
try: |
|
data = json.loads(strip_jsonc(raw)) |
|
except Exception as e: |
|
raise SystemExit(f"无法解析 VS Code settings.json: {e}") |
|
|
|
if not isinstance(data, dict): |
|
raise SystemExit("settings.json 根节点必须是 object") |
|
|
|
data["claudeCode.claudeProcessWrapper"] = wrapper |
|
data["claudeCode.disableLoginPrompt"] = True |
|
|
|
env_names = { |
|
"ANTHROPIC_BASE_URL": base, |
|
"ANTHROPIC_API_KEY": key, |
|
"CLAUDE_CODE_SKIP_AUTH_LOGIN": "1", |
|
"ANTHROPIC_MODEL": model, |
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": model, |
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": model, |
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": model, |
|
"CLAUDE_CODE_SUBAGENT_MODEL": model, |
|
} |
|
|
|
existing = data.get("claudeCode.environmentVariables", []) |
|
if isinstance(existing, dict): |
|
# rare alternate shape |
|
items = [{"name": k, "value": str(v)} for k, v in existing.items()] |
|
elif isinstance(existing, list): |
|
items = [x for x in existing if isinstance(x, dict) and isinstance(x.get("name"), str)] |
|
else: |
|
items = [] |
|
|
|
# remove old ANTHROPIC_AUTH_TOKEN to avoid Bearer auth on Go |
|
drop = set(env_names) | {"ANTHROPIC_AUTH_TOKEN"} |
|
items = [x for x in items if x.get("name") not in drop] |
|
for name, value in env_names.items(): |
|
items.append({"name": name, "value": value}) |
|
data["claudeCode.environmentVariables"] = items |
|
|
|
# Write pretty JSON (comments in original settings will be dropped) |
|
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
|
print(f"updated {path}") |
|
PY |
|
} |
|
|
|
patch_claude_settings() { |
|
local settings="$1" key="$2" model="$3" base="$4" |
|
mkdir -p "$(dirname "$settings")" |
|
if [ ! -f "$settings" ]; then |
|
printf '{\n}\n' > "$settings" |
|
fi |
|
backup_file "$settings" |
|
|
|
python3 - "$settings" "$key" "$model" "$base" <<'PY' |
|
import json, sys |
|
from pathlib import Path |
|
path, key, model, base = sys.argv[1:5] |
|
try: |
|
data = json.loads(Path(path).read_text(encoding="utf-8")) |
|
except Exception: |
|
data = {} |
|
if not isinstance(data, dict): |
|
data = {} |
|
data["model"] = model |
|
env = data.get("env") if isinstance(data.get("env"), dict) else {} |
|
env.update({ |
|
"ANTHROPIC_BASE_URL": base, |
|
"ANTHROPIC_API_KEY": key, |
|
"CLAUDE_CODE_SKIP_AUTH_LOGIN": "1", |
|
"ANTHROPIC_MODEL": model, |
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": model, |
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": model, |
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": model, |
|
"CLAUDE_CODE_SUBAGENT_MODEL": model, |
|
}) |
|
# remove auth token to avoid confusion |
|
env.pop("ANTHROPIC_AUTH_TOKEN", None) |
|
data["env"] = env |
|
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
|
print(f"updated {path}") |
|
PY |
|
} |
|
|
|
uninstall_all() { |
|
warn "卸载:移除 wrapper,并清理本脚本写入的 VS Code / Claude 配置键" |
|
rm -f "$WRAPPER_PATH" |
|
info "已删除 $WRAPPER_PATH(若存在)" |
|
|
|
if [ -f "$VS_SETTINGS" ]; then |
|
backup_file "$VS_SETTINGS" |
|
python3 - "$VS_SETTINGS" <<'PY' |
|
import json, re, sys |
|
from pathlib import Path |
|
path = sys.argv[1] |
|
raw = Path(path).read_text(encoding="utf-8") |
|
|
|
def strip_jsonc(s: str) -> str: |
|
out=[]; i=0; n=len(s); in_str=esc=False |
|
while i<n: |
|
c=s[i] |
|
if in_str: |
|
out.append(c) |
|
if esc: esc=False |
|
elif c=='\\': esc=True |
|
elif c=='"': in_str=False |
|
i+=1; continue |
|
if c=='"': |
|
in_str=True; out.append(c); i+=1; continue |
|
if c=='/' and i+1<n and s[i+1]=='/': |
|
while i<n and s[i]!='\n': i+=1 |
|
continue |
|
if c=='/' and i+1<n and s[i+1]=='*': |
|
i+=2 |
|
while i+1<n and not (s[i]=='*' and s[i+1]=='/'): i+=1 |
|
i+=2; continue |
|
out.append(c); i+=1 |
|
return re.sub(r',\s*([}\]])', r'\1', ''.join(out)) |
|
|
|
data=json.loads(strip_jsonc(raw)) |
|
data.pop('claudeCode.claudeProcessWrapper', None) |
|
data.pop('claudeCode.disableLoginPrompt', None) |
|
names={ |
|
'ANTHROPIC_BASE_URL','ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN', |
|
'CLAUDE_CODE_SKIP_AUTH_LOGIN','ANTHROPIC_MODEL', |
|
'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL', |
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL','CLAUDE_CODE_SUBAGENT_MODEL' |
|
} |
|
items=data.get('claudeCode.environmentVariables') |
|
if isinstance(items, list): |
|
data['claudeCode.environmentVariables']=[ |
|
x for x in items |
|
if not (isinstance(x, dict) and x.get('name') in names) |
|
] |
|
if not data['claudeCode.environmentVariables']: |
|
data.pop('claudeCode.environmentVariables', None) |
|
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') |
|
print('cleaned', path) |
|
PY |
|
fi |
|
|
|
if [ -f "$CLAUDE_SETTINGS" ]; then |
|
backup_file "$CLAUDE_SETTINGS" |
|
python3 - "$CLAUDE_SETTINGS" <<'PY' |
|
import json, sys |
|
from pathlib import Path |
|
path=sys.argv[1] |
|
data=json.loads(Path(path).read_text(encoding='utf-8')) |
|
data.pop('env', None) |
|
# don't force-remove model; user may still want it |
|
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') |
|
print('cleaned env from', path) |
|
PY |
|
fi |
|
|
|
info "✅ 卸载完成。请 Reload VS Code Window。" |
|
exit 0 |
|
} |
|
|
|
# ---------- main ---------- |
|
need_python |
|
|
|
if [ "$UNINSTALL" -eq 1 ]; then |
|
uninstall_all |
|
fi |
|
|
|
printf '%b\n' "${B}OpenCode Go → Claude Code (VS Code) 安装器${N}" |
|
echo "Base URL : $BASE_URL" |
|
echo "Model : $MODEL" |
|
echo "VS Code : $VS_SETTINGS" |
|
echo "Claude : $CLAUDE_SETTINGS" |
|
echo |
|
|
|
if [ -z "$API_KEY" ]; then |
|
API_KEY="$(read_key_hidden "请输入 OpenCode Go API Key (sk-...): ")" |
|
fi |
|
API_KEY="$(printf '%s' "$API_KEY" | tr -d '[:space:]')" |
|
[ -n "$API_KEY" ] || die "API Key 为空" |
|
case "$API_KEY" in |
|
sk-*) ;; |
|
*) warn "Key 不是以 sk- 开头,仍继续(若失败请检查)" ;; |
|
esac |
|
|
|
if [ "$SKIP_PROBE" -eq 0 ]; then |
|
probe_api "$API_KEY" |
|
fi |
|
|
|
write_wrapper |
|
patch_vscode_settings "$VS_SETTINGS" "$WRAPPER_PATH" "$API_KEY" "$MODEL" "$BASE_URL" |
|
patch_claude_settings "$CLAUDE_SETTINGS" "$API_KEY" "$MODEL" "$BASE_URL" |
|
|
|
cat <<EOF |
|
|
|
${G}${B}✅ 安装完成${N} |
|
|
|
接下来: |
|
1. VS Code 执行:Developer: Reload Window |
|
2. 新开一个 Claude 会话(不要点 Claude.ai / Console 登录) |
|
3. 发送「你好」测试 |
|
|
|
关键配置: |
|
- wrapper : ${WRAPPER_PATH} |
|
- VS Code : claudeCode.claudeProcessWrapper + environmentVariables |
|
- Claude : ~/.claude/settings.json 的 env/model |
|
|
|
卸载: |
|
bash $0 --uninstall |
|
|
|
说明文档见同 gist 的 README.md |
|
EOF |