#!/usr/bin/env bash # onboard-notify.sh — wire ANY machine's OpenCode to the ai-stack `notify` plugin. # The plugin sends an ntfy push whenever OpenCode asks for a permission/decision, # goes idle, or runs a risky command — so you get pinged when the agent needs you. # # GET YOUR COMMAND: open the OpenCode installer portal (Pocket ID SSO) — it reads your # ntfy target/token from your profile and shows a ready-to-paste one-liner WITH your # values baked in. Then run it here: # curl -fsSL https://static-ai-stack.dc7.io/install/notify | bash -s -- \ # install --ntfy-url https://ntfy.cr1.dc7.io/paas --ntfy-auth "Basic <...>" # # install [--global] [--project DIR] [--ntfy-url URL] [--ntfy-auth VAL] # status [--global] [--project DIR] # uninstall [--global] [--project DIR] # # HOW IT WORKS (OpenCode 2.x): # The plugin is fetched from the ai-stack static host and pinned until the next run. # The old self-updating LOADER is gone (design D1): it was a V1 plugin whose job was to # fetch and dynamically import another V1 plugin, so under V2 neither half ran. Direct # install also makes an offline session start deterministic. # # ntfy CONFIG travels as PLUGIN OPTIONS inside opencode.json, not a plaintext # notify.json sidecar — V1 needed the sidecar because its config could not pass values # to a plugin, which left a live credential sitting in the repo tree. The plugin reads # ctx.options.ntfy_url / ctx.options.ntfy_auth, falling back to NTFY_URL / NTFY_AUTH. # # notify installs as a DIRECTORY PACKAGE at .opencode/plugins/notify/ and IS listed in # the config `plugins` array — that is the only shape which receives options (an # auto-discovered loose file gets {}), and such an entry must name a directory. # Any stale V1 array entry / singular-dir copy is removed on install and uninstall. # # DESIGN GUARANTEES # • Idempotent: re-running install never duplicates config or clobbers your setup. # • Surgical: only OUR keys are added; a pre-existing opencode.json is preserved # (and backed up). Uninstall restores it byte-for-byte when possible. # • Per-project by default (writes ./.opencode/); --global writes ~/.config/opencode/. # # Requires: bash, jq, curl. # This script uses bashisms. Re-exec under bash when possible; for the piped one-liner # invoke with `bash -s` (NOT `sh`). if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 \ && [ -f "$0" ] && [ "$0" != "sh" ] && [ "$0" != "-" ]; then exec bash "$0" "$@" fi set -euo pipefail # ── defaults ────────────────────────────────────────────────────────────────── # Where the ai-stack OpenCode plugins are published (static host). ONBOARD_HOST_DEFAULT="https://static-ai-stack.dc7.io" # ── colors / logging ──────────────────────────────────────────────────────────── if [ -t 1 ]; then C_G=$'\033[32m'; C_Y=$'\033[33m'; C_R=$'\033[31m'; C_B=$'\033[1m'; C_0=$'\033[0m'; else C_G= C_Y= C_R= C_B= C_0=; fi log() { printf '%s%s%s\n' "$C_B" "$*" "$C_0"; } ok() { printf '%s✓%s %s\n' "$C_G" "$C_0" "$*"; } warn() { printf '%s!%s %s\n' "$C_Y" "$C_0" "$*" >&2; } die() { printf '%s✗%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; } # ── arg parsing ────────────────────────────────────────────────────────────────── CMD="${1:-}"; shift || true SCOPE="project" # project | global PROJECT_DIR="$PWD" NTFY_URL_ARG="" NTFY_AUTH_ARG="" CFG_DIR_PREEXISTED=1 while [ $# -gt 0 ]; do case "$1" in --global) SCOPE="global" ;; --project) PROJECT_DIR="${2:?}"; shift ;; --ntfy-url) NTFY_URL_ARG="${2:?}"; shift ;; --ntfy-auth) NTFY_AUTH_ARG="${2:?}"; shift ;; -h|--help) CMD="help" ;; *) die "unknown arg: $1" ;; esac shift done # Resolve the config dir + a stable "ours" marker inside it. cfg_dir() { if [ "$SCOPE" = "global" ]; then echo "$HOME/.config/opencode"; else echo "$PROJECT_DIR/.opencode"; fi } CFG_DIR="$(cfg_dir)" CFG_JSON="$CFG_DIR/opencode.json" BACKUP="$CFG_DIR/.opencode.json.notify-backup" # pre-existing config snapshot OURS_MARKER="$CFG_DIR/.notify-managed" # lists what WE created # ── git hygiene: keep local state + secrets out of commits ─────────────────────── # .opencode/ mixes SHAREABLE config (opencode.json) with PER-MACHINE state (managed # markers, pre-install backups) and SECRETS (the notify sidecar). Nothing used to stop # `git add .opencode/` from committing rows 2-3 — which is exactly how a live ntfy # credential + LiteLLM key reached this repo's remote history. Append-only + idempotent: # we never rewrite a user's .gitignore, only add our missing lines under a marked header. # Duplicated verbatim across the onboarders (they're standalone by design, no shared lib). GI_HEADER="# ── ai-stack onboarders (local state — do not commit) ──" ensure_gitignore() { local gi="$CFG_DIR/.gitignore" line mkdir -p "$CFG_DIR" [ -f "$gi" ] || : > "$gi" # An existing .gitignore may lack a trailing newline (opencode writes one like that), # which would glue our header onto its last entry and corrupt both. Terminate it first. if [ -s "$gi" ] && [ "$(tail -c1 "$gi" 2>/dev/null | wc -l)" -eq 0 ]; then printf '\n' >> "$gi" fi grep -qxF "$GI_HEADER" "$gi" 2>/dev/null || printf '%s\n' "$GI_HEADER" >> "$gi" for line in '.*-managed' '.opencode.json.*-backup' 'plugin/notify.json'; do grep -qxF "$line" "$gi" 2>/dev/null || printf '%s\n' "$line" >> "$gi" done } # PLURAL `plugins/`, not `plugin/` (design D4b). That is the only discovery directory # the OpenCode 2.x docs define. The singular form is currently also discovered, but it # is undocumented — an undocumented alias can vanish in a point release, and the failure # is silent (plugins simply stop loading, with no error at the call site). # notify ships as a DIRECTORY PACKAGE under the documented plural dir (design D4b). # Two measured constraints force this shape: # * only a plugin named by a `plugins` CONFIG ENTRY receives ctx.options — an # auto-discovered file sees {} — and notify needs the ntfy url/token; # * a `plugins` entry must point at a DIRECTORY ("configured plugin path must be a # directory"), so a bare notify.ts cannot carry options at all. plugin_pkg_rel="./plugins/notify" plugin_dir_rel=".opencode/plugins/notify" # ── plugin install: the V2 plugin itself ────────────────────────────────────────── # NO LOADER (design D1). The old notify-loader.ts was a V1 plugin whose job was to fetch # and dynamically import another V1 plugin; under OpenCode 2.x neither half ran. The # plugin is fetched directly and pinned until the next install. fetch_plugin() { local dir="$1" mkdir -p "$dir" if curl -fsSL --max-time 60 "${ONBOARD_HOST_DEFAULT}/notify/index.ts" -o "$dir/index.ts" 2>/dev/null \ && grep -q 'Plugin.define' "$dir/index.ts" 2>/dev/null \ && curl -fsSL --max-time 60 "${ONBOARD_HOST_DEFAULT}/notify/package.json" -o "$dir/package.json" 2>/dev/null; then return 0 fi return 1 } write_config() { if [ ! -f "$OURS_MARKER" ]; then [ -d "$CFG_DIR" ] && CFG_DIR_PREEXISTED=1 || CFG_DIR_PREEXISTED=0 else CFG_DIR_PREEXISTED="$(sed -n 's/^dir_preexisted=//p' "$OURS_MARKER" 2>/dev/null || echo 1)" fi mkdir -p "$CFG_DIR" if [ ! -f "$OURS_MARKER" ]; then if [ -f "$CFG_JSON" ]; then cp "$CFG_JSON" "$BACKUP"; else echo '__none__' > "$BACKUP"; fi fi local base='{}' [ -f "$CFG_JSON" ] && base="$(cat "$CFG_JSON")" printf '%s' "$base" | jq -e . >/dev/null 2>&1 || die "existing $CFG_JSON is not valid JSON; aborting" # Strip the V1 singular `plugin` array an older installer wrote. V2 uses `plugins`, # which this script rewrites below; leaving the V1 key behind would keep pointing at # the retired .opencode/plugin/notify.ts path. printf '%s' "$base" | jq --arg legacy ".opencode/plugin/notify.ts" ' (if (.plugin != null) then .plugin = ((.plugin - [$legacy]) | unique) | (if (.plugin == []) then del(.plugin) else . end) else . end) ' > "$CFG_JSON.tmp" mv "$CFG_JSON.tmp" "$CFG_JSON" # Install the directory package, and REMOVE what a previous install left behind: # the singular plugin/ copy AND the plaintext notify.json sidecar. Leaving either # means the surface loads notify twice, or keeps a credential on disk we no longer use. fetch_plugin "$CFG_DIR/plugins/notify" || \ warn "could not fetch the notify plugin from $ONBOARD_HOST_DEFAULT — plugin not installed." rm -f "$CFG_DIR/plugin/notify.ts" "$CFG_DIR/plugin/notify.json" \ "$CFG_DIR/plugins/notify.ts" 2>/dev/null || true rmdir "$CFG_DIR/plugin" 2>/dev/null || true # ntfy config travels as PLUGIN OPTIONS, not as a plaintext sidecar. # # V1 had to use a sidecar because its config schema had no way to pass values to a # plugin — which put a live Basic credential in the repo tree, where it is a finding # rather than a mechanism. V2 passes `options` through the `plugins` entry, so the # value lives in the user's own config and the ported plugin reads # ctx.options.ntfy_url / ctx.options.ntfy_auth, falling back to NTFY_URL / NTFY_AUTH. # # The entry is rewritten (not appended) on every run so re-installing is idempotent. base="$(cat "$CFG_JSON")" printf '%s' "$base" | jq \ --arg pkg "$plugin_pkg_rel" \ --arg url "$NTFY_URL_ARG" \ --arg auth "$NTFY_AUTH_ARG" ' def opts: {} | (if ($url|length)>0 then .ntfy_url=$url else . end) | (if ($auth|length)>0 then .ntfy_auth=$auth else . end); .plugins = (((.plugins // []) | map(select((type == "object" and .package == $pkg) | not)) | map(select(. != $pkg))) + [ (if (opts | length) > 0 then {package: $pkg, options: opts} else $pkg end) ]) ' > "$CFG_JSON.tmp" mv "$CFG_JSON.tmp" "$CFG_JSON" if [ -n "$NTFY_AUTH_ARG" ]; then chmod 600 "$CFG_JSON" 2>/dev/null || true; fi cat > "$OURS_MARKER" </dev/null; then rm -f "$CFG_JSON" ok "removed $CFG_JSON (we created it)" else cp "$BACKUP" "$CFG_JSON" ok "restored $CFG_JSON from pre-install backup" fi rm -f "$BACKUP" else if [ -f "$CFG_JSON" ]; then # Remove our V2 `plugins` entry (string or {package,options} form) and any # leftover V1 `plugin` entry. jq --arg pkg "./plugins/notify" --arg legacy ".opencode/plugin/notify.ts" ' (if (.plugins != null) then .plugins = (.plugins | map(select((type == "object" and .package == $pkg) | not)) | map(select(. != $pkg))) | (if (.plugins == []) then del(.plugins) else . end) else . end) | (if (.plugin != null) then .plugin = ((.plugin // []) - [$legacy]) | (if (.plugin == []) then del(.plugin) else . end) else . end) ' "$CFG_JSON" > "$CFG_JSON.tmp" && mv "$CFG_JSON.tmp" "$CFG_JSON" ok "surgically removed our keys from $CFG_JSON" fi fi local dir_preexisted; dir_preexisted="$(sed -n 's/^dir_preexisted=//p' "$OURS_MARKER" 2>/dev/null || echo 1)" rm -rf "$CFG_DIR/plugins/notify" rm -f "$CFG_DIR/plugin/notify.ts" "$CFG_DIR/plugin/notify.json" \ "$CFG_DIR/plugins/notify.ts" "$OURS_MARKER" rmdir "$CFG_DIR/plugin" "$CFG_DIR/plugins" 2>/dev/null || true # Clear the retired loader's self-update cache (design D1). rm -rf "$HOME/.cache/ai-stack/notify" 2>/dev/null || true if [ "$dir_preexisted" = "0" ] && [ "$SCOPE" = "project" ]; then rmdir "$CFG_DIR" 2>/dev/null && ok "removed empty $CFG_DIR" || true fi } # ── commands ────────────────────────────────────────────────────────────────────── cmd_install() { need jq; need curl log "== install ($SCOPE) → $CFG_DIR ==" command -v opencode >/dev/null 2>&1 || warn "opencode not found — install it to use the plugin." write_config cmd_status } cmd_status() { need jq log "== status ($SCOPE) ==" if [ -f "$OURS_MARKER" ]; then printf ' config : managed at %s\n' "$CFG_JSON" # notify is a DIRECTORY PACKAGE referenced from `plugins` — that is the only shape # that receives ctx.options (design D4b), so we check the package entrypoint. [ -f "$CFG_DIR/plugins/notify/index.ts" ] \ && printf ' plugin package present ✓\n' \ || printf ' plugin package MISSING from %s/plugins/notify\n' "$CFG_DIR" # ntfy config travels as plugin OPTIONS in opencode.json, not a plaintext sidecar. local url; url="$(jq -r '[.plugins[]? | select(type=="object" and .package=="./plugins/notify") | .options.ntfy_url] | first // empty' "$CFG_JSON" 2>/dev/null)" if [ -n "$url" ]; then printf ' NTFY_URL = %s ✓ (plugin options)\n' "$url" elif [ -n "${NTFY_URL:-}" ]; then printf ' NTFY_URL from env ✓\n' else printf ' NTFY_URL NOT set — notifications OFF\n'; fi else printf ' config : not installed in %s\n' "$CFG_DIR" fi local plug="$CFG_DIR/plugins/notify/index.ts" if [ -f "$plug" ] && grep -q 'Plugin.define' "$plug" 2>/dev/null; then printf ' plugin : V2 plugin installed ✓\n' elif [ -f "$plug" ]; then printf ' plugin : present but not a V2 plugin (no Plugin.define)\n' else printf ' plugin : not installed\n' fi } cmd_uninstall() { need jq log "== uninstall ($SCOPE) → $CFG_DIR ==" restore_config ok "uninstall complete." } cmd_help() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; } case "$CMD" in install) cmd_install ;; status) cmd_status ;; uninstall) cmd_uninstall ;; help|"" ) cmd_help ;; *) die "unknown command: $CMD (use install|status|uninstall|help)" ;; esac