#!/usr/bin/env bash # onboard-base.sh — write the ai-stack BASE project policy into OpenCode config: sane # bash/edit PERMISSIONS, the YAML/Kubernetes LSP, and (project scope only) auto-wired # INSTRUCTIONS files. These are the project-level defaults the service installers # (litellm/agentmemory/notify) deliberately DON'T touch — run this once per new project # so every repo starts from the same baseline. # # FIRST-TIMER ONE-LINER (from your project root): # curl -fsSL https://static-ai-stack.dc7.io/install/base | bash -s -- install # # install [--global] [--project DIR] [--no-instructions] [--no-lsp] [--no-permission] # status [--global] [--project DIR] # uninstall [--global] [--project DIR] # # WHAT IT WRITES (surgically merged into .opencode/opencode.json): # • permission — bash: ask on `git push*` and `rm -rf*`, allow the rest; edit: allow; # external_directory: ask. (Guard rails without nagging.) # • lsp.yaml — yaml-language-server with the Kubernetes schema for *.yaml/*.yml. # • instructions — (PROJECT scope only) auto-detected AGENTS.md + .agents/rules/*.md + # .agents/memory/MEMORY.md, referenced RELATIVE to .opencode/ (../…). # Only files that actually exist are added. Skipped for --global # (a global config has no single project to point at). # # DESIGN GUARANTEES (mirror of the other onboarders): # • Idempotent: re-running never duplicates keys 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. # 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 # ── 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_DIR="$PWD" WANT_INSTRUCTIONS=1; WANT_LSP=1; WANT_PERMISSION=1 CFG_DIR_PREEXISTED=1 while [ $# -gt 0 ]; do case "$1" in --global) SCOPE="global" ;; --project) PROJECT_DIR="${2:?}"; shift ;; --no-instructions) WANT_INSTRUCTIONS=0 ;; --no-lsp) WANT_LSP=0 ;; --no-permission) WANT_PERMISSION=0 ;; -h|--help) CMD="help" ;; *) die "unknown arg: $1" ;; esac shift done 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.base-backup" OURS_MARKER="$CFG_DIR/.base-managed" # ── 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 } # The permission + LSP blocks we manage (kept in one place so uninstall can subtract them). PERMISSION_JSON='{ "bash": { "git push*": "ask", "rm -rf*": "ask", "*": "allow" }, "edit": "allow", "external_directory": "ask" }' LSP_YAML_JSON='{ "command": ["yaml-language-server", "--stdio"], "extensions": [".yaml", ".yml"], "initialization": { "yaml": { "schemas": { "kubernetes": ["*.yaml", "*.yml"] }, "validate": true, "completion": true, "hover": true } } }' # Build the instructions array (PROJECT scope only), RELATIVE to .opencode/ (so `../`). # Only include paths that actually exist under the project root. Globs are kept verbatim # if their parent dir exists (opencode expands them at load time). build_instructions() { [ "$SCOPE" = "project" ] || { echo "null"; return; } local root="$PROJECT_DIR" out=() [ -f "$root/AGENTS.md" ] && out+=("../AGENTS.md") [ -d "$root/.agents/rules" ] && out+=("../.agents/rules/*.md") [ -f "$root/.agents/memory/MEMORY.md" ] && out+=("../.agents/memory/MEMORY.md") [ ${#out[@]} -eq 0 ] && { echo "null"; return; } printf '%s\n' "${out[@]}" | jq -R . | jq -s . } 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" local instr; instr="$(build_instructions)" # Surgical merge. We only SET our keys; we never clobber a user's existing values for # keys we don't own. For `instructions` we UNION (unique) so we don't drop the user's. printf '%s' "$base" | jq \ --argjson want_perm "$WANT_PERMISSION" \ --argjson want_lsp "$WANT_LSP" \ --argjson want_instr "$WANT_INSTRUCTIONS" \ --argjson perm "$PERMISSION_JSON" \ --argjson lspyaml "$LSP_YAML_JSON" \ --argjson instr "$instr" ' (if $want_perm == 1 then .permission = $perm else . end) | (if $want_lsp == 1 then .lsp = (.lsp // {}) | .lsp.yaml = $lspyaml else . end) | (if ($want_instr == 1 and $instr != null) then .instructions = (((.instructions // []) + $instr) | unique) else . end) ' > "$CFG_JSON.tmp" mv "$CFG_JSON.tmp" "$CFG_JSON" 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" elif [ -f "$CFG_JSON" ]; then # Surgically subtract only what WE added (best-effort; the backup path above is primary). local instr; instr="$(build_instructions)" jq --argjson perm "$PERMISSION_JSON" --argjson instr "${instr:-null}" ' (if (.permission == $perm) then del(.permission) else . end) | (if (.lsp.yaml != null) then del(.lsp.yaml) | (if (.lsp == {}) then del(.lsp) else . end) else . end) | (if ($instr != null and (.instructions != null)) then .instructions = ((.instructions) - $instr) | (if (.instructions == []) then del(.instructions) else . end) else . end)' \ "$CFG_JSON" > "$CFG_JSON.tmp" && mv "$CFG_JSON.tmp" "$CFG_JSON" ok "surgically removed base keys from $CFG_JSON" fi local dir_preexisted; dir_preexisted="$(sed -n 's/^dir_preexisted=//p' "$OURS_MARKER" 2>/dev/null || echo 1)" rm -f "$OURS_MARKER" 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 log "== install ($SCOPE) → $CFG_DIR ==" command -v opencode >/dev/null 2>&1 || warn "opencode not found — install it to use this config." command -v yaml-language-server >/dev/null 2>&1 || \ warn "yaml-language-server not on PATH — the yaml LSP is registered but won't spawn until installed (npm i -g yaml-language-server)." write_config cmd_status } cmd_status() { need jq log "== status ($SCOPE) ==" if [ -f "$OURS_MARKER" ] && [ -f "$CFG_JSON" ]; then printf ' config : managed at %s\n' "$CFG_JSON" jq -e '.permission.edit != null' "$CFG_JSON" >/dev/null 2>&1 \ && printf ' permission block present ✓\n' \ || printf ' permission block absent\n' jq -e '.lsp.yaml != null' "$CFG_JSON" >/dev/null 2>&1 \ && printf ' lsp.yaml present ✓\n' \ || printf ' lsp.yaml absent\n' local ni; ni="$(jq -r '(.instructions // []) | length' "$CFG_JSON" 2>/dev/null || echo 0)" printf ' instructions: %s entr%s\n' "$ni" "$([ "$ni" = "1" ] && echo y || echo ies)" else printf ' config : not installed in %s\n' "$CFG_DIR" fi } cmd_uninstall() { need jq; log "== uninstall ($SCOPE) → $CFG_DIR =="; restore_config; ok "uninstall complete."; } cmd_help() { sed -n '2,32p' "$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