#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Small Paper installer for opencode. Adds a `smallpaper` provider to your opencode config. curl -fsSL https://smallpaper.io/install.sh | bash curl -fsSL https://smallpaper.io/install.sh | bash -s -- --global --key 41920837556120947381 Merges into your config with a timestamped backup; touches only Small Paper's provider entry. EOF } SITE="${SMALLPAPER_SITE_URL:-https://smallpaper.io}" API="${SMALLPAPER_API_URL:-https://api.smallpaper.io}" WEB="${SMALLPAPER_CHAT_URL:-https://chat.smallpaper.io}" GLOBAL_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json" LOCAL_CFG="$PWD/opencode.json" GLOBAL_PLUGIN_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugin" LOCAL_PLUGIN_DIR="$PWD/.opencode/plugin" PLUGIN_MIN="1.3.4" OPENCODE_INSTALL="curl -fsSL https://opencode.ai/v2/install | bash" MODE=""; KEY=""; DRY=0; PAIR=0 while [ $# -gt 0 ]; do case "$1" in --global) MODE=global ;; --local) MODE=local ;; --key) KEY="${2:-}"; shift ;; --pair) PAIR=1 ;; --dry-run) DRY=1 ;; -h|--help) usage; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac shift done say() { printf '%s\n' "$*"; } warn() { printf '\033[33m%s\033[0m\n' "$*" >&2; } die() { printf '\033[31m%s\033[0m\n' "$*" >&2; exit 1; } umask_mode() { chmod "$(printf '%03o' "$(( 0666 & ~0$(umask) ))")" "$1"; } TTY="" [ -r /dev/tty ] && TTY=/dev/tty ask() { [ -n "$TTY" ] || die "No terminal available. Re-run with flags, e.g.: curl -fsSL $SITE/install.sh | bash -s -- --global --key " local __v; read -r -p "$1" __v < "$TTY"; printf '%s' "$__v" } [ "$(id -u)" != 0 ] || die "Do not run this as root — it writes into your home directory." command -v python3 >/dev/null || die "python3 is required (used to merge your config safely)." command -v curl >/dev/null || die "curl is required." find_opencode() { local c for c in "$(command -v opencode 2>/dev/null || true)" \ "$HOME/.opencode/bin/opencode" \ "${XDG_DATA_HOME:-$HOME/.local/share}/opencode/bin/opencode" \ "$HOME/.local/bin/opencode" \ "$HOME/.bun/bin/opencode" \ "$HOME/.npm-global/bin/opencode" \ /opt/homebrew/bin/opencode \ /usr/local/bin/opencode; do [ -n "$c" ] && [ -x "$c" ] && { printf '%s' "$c"; return 0; } done return 1 } bounded() { if command -v timeout >/dev/null; then timeout 20 "$@"; else "$@"; fi; } OPENCODE="$(find_opencode)" || { warn "opencode is not installed: not on your PATH, and not where its installers put it." say "Install it first, then re-run this:" say " $OPENCODE_INSTALL" say "" say "(Not installed for you: it is another project's software.)" exit 1 } if ! OPENCODE_VERSION="$(bounded "$OPENCODE" --version 2>/dev/null)"; then die "Found $OPENCODE, but it does not run (opencode --version failed). Reinstall it with: $OPENCODE_INSTALL" fi OPENCODE_VERSION="${OPENCODE_VERSION%%$'\n'*}" OPENCODE_SEMVER="$(printf '%s' "$OPENCODE_VERSION" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)" say "Found opencode ${OPENCODE_SEMVER:-${OPENCODE_VERSION:-(unknown version)}} at $OPENCODE" case ":$PATH:" in *":$(dirname "$OPENCODE"):"*) : ;; *) warn "Note: $(dirname "$OPENCODE") is not on this shell's PATH yet. Open a new terminal before running opencode." ;; esac if [ -z "$MODE" ]; then say "Where should the Small Paper provider go?" say " 1) globally, for every project $GLOBAL_CFG" say " 2) this project only $LOCAL_CFG" case "$(ask 'Choice [1]: ')" in 2) MODE=local ;; *) MODE=global ;; esac fi [ "$MODE" = global ] && CFG="$GLOBAL_CFG" || CFG="$LOCAL_CFG" [ "$MODE" = global ] && PLUGIN_DIR="$GLOBAL_PLUGIN_DIR" || PLUGIN_DIR="$LOCAL_PLUGIN_DIR" PLUGIN="$PLUGIN_DIR/smallpaper.js" predates() { case "$OPENCODE_SEMVER" in ""|0.0.0) return 1 ;; esac local have want i IFS=. read -r -a have <<<"$OPENCODE_SEMVER" IFS=. read -r -a want <<<"$1" for i in 0 1 2; do [ "${have[i]}" -eq "${want[i]}" ] || { [ "${have[i]}" -lt "${want[i]}" ]; return; } done return 1 } pair_flow() { local start hostname payload device user ttl interval url code body deadline hostname="$(hostname 2>/dev/null || echo unknown)" payload="$(python3 -c 'import json,sys; print(json.dumps({"hostname":sys.argv[1],"client":"opencode"}))' "$hostname")" local sfile scode sfile="$(mktemp)" scode="$(curl -s -o "$sfile" -w '%{http_code}' -X POST "$API/api/pair/start" \ -H 'content-type: application/json' \ -d "$payload")" || scode=000 start="$(cat "$sfile")"; rm -f "$sfile" case "$scode" in 200) : ;; 429) die "Too many pairing attempts from this address. Wait a few minutes, or re-run with --key to skip pairing." ;; 000) die "Could not reach $API. Check the address and your connection." ;; *) die "$API returned HTTP $scode starting the pairing." ;; esac read -r device user ttl interval < <(printf '%s' "$start" | python3 -c \ 'import json,sys;d=json.load(sys.stdin);print(d["device_code"],d["user_code"],d["ttl"],d.get("interval",2))') url="$WEB/pair" say "" say "Open this, sign in, and approve the pairing:" say " $url" say "" say "Type this code: $user" say "" say "No browser here? Open $url on any device." { command -v xdg-open >/dev/null && xdg-open "$url" >/dev/null 2>&1 & } \ || { command -v open >/dev/null && open "$url" >/dev/null 2>&1 & } || true say "Waiting for approval ($((ttl / 60)) minutes)..." deadline=$((SECONDS + ttl)) while [ "$SECONDS" -lt "$deadline" ]; do body="$(mktemp)"; code="$(curl -s -o "$body" -w '%{http_code}' "$API/api/pair/$device")" case "$code" in 200) KEY="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["api_key"])' "$body")" rm -f "$body"; say "Paired."; return 0 ;; 202) rm -f "$body"; sleep "$interval" ;; 410) rm -f "$body" say "" if [ -n "$TTY" ] && [ "$(ask 'Pairing expired or was denied. Try again? [y/N]: ')" = y ]; then pair_flow; return $? fi die "Pairing not completed." ;; 429) rm -f "$body"; sleep 5 ;; *) rm -f "$body"; sleep "$interval" ;; esac done die "Timed out waiting for approval." } KEY="$(printf '%s' "$KEY" | tr -d '[:space:]')" if [ -z "$KEY" ]; then if [ "$PAIR" = 1 ]; then pair_flow else say "" say "How do you want to authenticate?" say " 1) approve in your browser (creates a key just for this machine)" say " 2) paste your account number" if [ "$(ask 'Choice [1]: ')" = 2 ]; then KEY="$(ask 'Account number: ')" KEY="$(printf '%s' "$KEY" | tr -d '[:space:]')" [ -n "$KEY" ] || die "No key entered." else pair_flow fi fi fi NEW="$(mktemp)"; trap 'rm -f "$NEW"' EXIT curl -fsS "$API/docs/opencode.json" -o "$NEW" \ || die "Could not fetch $API/docs/opencode.json" INLINE=0; [ "$MODE" = global ] && INLINE=1 merge() { KEY="$KEY" INLINE="$INLINE" python3 - "$CFG" "$NEW" "$1" <<'PY' import json, os, sys cfg, new, out = sys.argv[1:4] incoming = json.load(open(new)) try: with open(cfg) as fh: current = json.load(fh) except FileNotFoundError: current = {} except json.JSONDecodeError as e: sys.exit(f"{cfg} is not valid JSON ({e}); refusing to touch it. Fix or move it, then re-run.") if not isinstance(current, dict): sys.exit(f"{cfg} is not a JSON object; refusing to touch it.") block = incoming["provider"]["smallpaper"] if os.environ.get("INLINE") == "1": block["options"]["apiKey"] = os.environ["KEY"] current.setdefault("$schema", incoming["$schema"]) native = current.get("providers") if isinstance(native, dict) and native.pop("smallpaper", None) is not None: print("Replacing the opencode 2 style providers.smallpaper entry with this one.", file=sys.stderr) providers = current.setdefault("provider", {}) if not isinstance(providers, dict): sys.exit("`provider` in your config is not an object; refusing to touch it.") providers["smallpaper"] = block text = json.dumps(current, indent=2) + "\n" if out == "-": sys.stdout.write(text) else: with open(out, "w") as fh: fh.write(text) PY } if [ "$DRY" = 1 ]; then say ""; say "--dry-run: would write $CFG" merge - say "" if predates "$PLUGIN_MIN"; then say "--dry-run: would skip the model-refresh plugin (it needs opencode $PLUGIN_MIN or newer)" else say "--dry-run: would also write $PLUGIN" say " (the model-refresh plugin — keeps this model list current without re-running)" fi exit 0 fi mkdir -p "$(dirname "$CFG")" if [ -f "$CFG" ]; then BACKUP="$CFG.bak.$(date +%Y%m%d%H%M%S)" cp "$CFG" "$BACKUP" say "Backed up your config to $BACKUP" fi TMP="$(mktemp "$(dirname "$CFG")/.opencode.json.XXXXXX")" trap 'rm -f "$NEW" "$TMP"' EXIT merge "$TMP" mv "$TMP" "$CFG" if [ "$INLINE" = 1 ]; then chmod 600 "$CFG" else umask_mode "$CFG" fi say "Wrote $CFG" if predates "$PLUGIN_MIN"; then warn "Skipped the model-refresh plugin: it needs opencode $PLUGIN_MIN or newer, and this is $OPENCODE_SEMVER. Update opencode and re-run this to get it; until then, re-run this when models change." elif mkdir -p "$PLUGIN_DIR" 2>/dev/null; then PTMP="$(mktemp "$PLUGIN_DIR/.smallpaper.js.XXXXXX")" || PTMP="" if [ -n "$PTMP" ] && curl -fsS "$API/docs/opencode-plugin.js" -o "$PTMP"; then umask_mode "$PTMP" mv "$PTMP" "$PLUGIN" say "Wrote $PLUGIN — your model list now refreshes itself." else rm -f "$PTMP" warn "Could not install the model-refresh plugin from $API/docs/opencode-plugin.js. Your config still works; re-run to retry." fi else warn "Could not create $PLUGIN_DIR; skipped the model-refresh plugin. Re-run this script when models change." fi ME="$(mktemp)"; CODE="$(curl -s -o "$ME" -w '%{http_code}' \ -H "authorization: Bearer $KEY" "$API/api/me")" if [ "$CODE" = 200 ]; then python3 - "$ME" <<'PY' import json, sys d = json.load(open(sys.argv[1])) paid = d.get("balance_microdollars", 0) or 0 has_pass = bool(d.get("has_pass") or d.get("pass")) pass_obj = d.get("pass") pass_note = f" (active pass: {pass_obj.get('label', 'yes')})" if isinstance(pass_obj, dict) else (" (active pass)" if has_pass else "") print(f"Account {d.get('tenant_id')}: ${paid/1e6:.2f}{pass_note}") if not has_pass and paid <= 0: print("\n\033[33mWARNING: no balance.\033[0m The API returns 402 insufficient_balance") print("until you top up or activate a pass. Fund the account first.") PY else warn "Could not verify the key against $API/api/me (HTTP $CODE)." warn "The config is written; check your account number if calls fail." fi rm -f "$ME" if [ "$INLINE" != 1 ]; then SAFE_KEY="$(printf '%s' "$KEY" | sed "s/'/'\\\\''/g")" say "" say "Your project config references an environment variable, so add this to your shell:" say " export SMALLPAPER_API_KEY='$SAFE_KEY'" say "(Project configs are usually committed, so the key stays out.)" fi say "" say "Done. Run 'opencode', then /models, and pick a Small Paper model."