Files
pi-extensions/install.sh
T
joakimp 143a2145e1 install.sh: skip hook activation on a clone this user cannot configure
activate_hooks assumed SCRIPT_DIR is a clone the caller owns. pi-devbox bakes
this repo root-owned under /opt/pi-extensions and runs install.sh as
`developer` on every boot: git refuses the repo ("dubious ownership"),
`config --local` fails (silenced), `cur` is empty, and the unguarded
`git config core.hooksPath hooks` exits 128 -- under set -euo pipefail that
aborted the installer and the entrypoint printed
"WARN: pi-extensions install.sh failed (continuing)" on every boot of every
device. Damage was nil (activate_hooks is the last real step; all symlinks
were already created) but a WARN that always fires is a WARN nobody reads.

Now: resolve the git dir and require .git/config to be writable; otherwise
print one note and return 0. Hooks are for clones you commit from, and a
vendored read-only copy is not one.

Measured (function extracted, run under set -euo pipefail, 8 cases):
  old fn  /opt/pi-extensions as developer   rc=128 "not in a git directory"
  old fn  root-owned throwaway clone        rc=128
  new fn  /opt/pi-extensions, root-owned throwaway, no .git, .git/config 444
                                            rc=0  NOTE "Repo hooks skipped"
  new fn  writable clone, hooksPath unset   rc=0  activated, config reads "hooks"
  new fn  writable clone, hooksPath=hooks   rc=0  "already active"
  neither root-owned config was modified. shellcheck: only the pre-existing
  SC2155 at line 170.
2026-09-22 17:16:28 +02:00

260 lines
9.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# install.sh — install pi-extensions
#
# Symlinks each extension in extensions/ into ~/.pi/agent/extensions/ so pi
# loads them automatically on every session. Idempotent and non-destructive.
#
# Usage:
# ./install.sh install all extensions
# ./install.sh --only ssh-controlmaster install one extension
# ./install.sh --only "ext1,ext2" install a subset
# ./install.sh --skip "ext1,ext2" install all except these
# ./install.sh --yes skip confirmation prompt
# ./install.sh --uninstall remove symlinks that point into this repo
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
EXTENSIONS_SRC="${SCRIPT_DIR}/extensions"
EXTENSIONS_DEST="${HOME}/.pi/agent/extensions"
PI_AGENT_DIR="${HOME}/.pi/agent"
# ── helpers ──────────────────────────────────────────
ok() { printf ' \e[32m✓\e[0m %s\n' "$*"; }
note() { printf '==> %s\n' "$*"; }
warn() { printf ' \e[33m!\e[0m %s\n' "$*" >&2; }
err() { printf ' \e[31m✗\e[0m %s\n' "$*" >&2; }
confirm() {
[[ "$ASSUME_YES" == "yes" ]] && return 0
read -r -p "Proceed? [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]]
}
link_into_repo() {
local target
[[ -L "$1" ]] || return 1
target=$(readlink -f "$1" 2>/dev/null || true)
[[ "$target" == "$SCRIPT_DIR"/* ]]
}
require_pi_installed() {
if [[ ! -d "$PI_AGENT_DIR" ]]; then
err "pi not detected at $PI_AGENT_DIR"
printf ' Install pi first: https://github.com/earendil-works/pi\n'
printf ' Re-run after `pi --help` (first run creates ~/.pi/agent/).\n'
exit 4
fi
mkdir -p "$EXTENSIONS_DEST"
ok "pi detected at $PI_AGENT_DIR"
}
# ── args ─────────────────────────────────────────────
ACTION="install"
ASSUME_YES="no"
ONLY="" # comma-separated names to include (empty = all)
SKIP="" # comma-separated names to exclude (empty = none)
while [[ $# -gt 0 ]]; do
case "$1" in
--uninstall) ACTION="uninstall"; shift ;;
-y|--yes) ASSUME_YES="yes"; shift ;;
--only) ONLY="$2"; shift 2 ;;
--only=*) ONLY="${1#--only=}"; shift ;;
--skip) SKIP="$2"; shift 2 ;;
--skip=*) SKIP="${1#--skip=}"; shift ;;
-h|--help)
cat <<EOF
install.sh — install pi-extensions
Usage:
./install.sh install all extensions
./install.sh --only ssh-controlmaster install one extension
./install.sh --only "ext1,ext2" install a subset
./install.sh --skip "ext1,ext2" install all except these
./install.sh --yes skip confirmation prompt
./install.sh --uninstall remove symlinks that point into this repo
--only and --skip accept comma-separated extension names (without .ts suffix).
--only takes precedence over --skip if both are given.
Each .ts file in extensions/ is symlinked into ~/.pi/agent/extensions/.
Existing files already symlinked into this repo are left alone.
Existing real files or foreign symlinks are backed up with a timestamp.
EOF
exit 0 ;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# ── build install set ─────────────────────────────────
# INSTALL_SET: space-delimited bare names (no .ts suffix). Bash 3 compatible.
INSTALL_SET=""
in_install_set() { [[ " $INSTALL_SET " == *" $1 "* ]]; }
build_install_set() {
local n f bare entry new
if [[ -n "$ONLY" ]]; then
# Explicit allowlist — only install what's named
IFS=',' read -ra names <<< "$ONLY"
for n in "${names[@]}"; do
INSTALL_SET="${INSTALL_SET:+$INSTALL_SET }${n%.ts}"
done
else
# Start with everything present on disk, then remove --skip entries
for f in "${EXTENSIONS_SRC}"/*.ts; do
[[ -e "$f" ]] && INSTALL_SET="${INSTALL_SET:+$INSTALL_SET }$(basename "$f" .ts)"
done
if [[ -n "$SKIP" ]]; then
IFS=',' read -ra names <<< "$SKIP"
for n in "${names[@]}"; do
bare="${n%.ts}"
new=""
for entry in $INSTALL_SET; do
[[ "$entry" == "$bare" ]] || new="${new:+$new }$entry"
done
INSTALL_SET="$new"
done
fi
fi
}
# ── install ──────────────────────────────────────────
do_install() {
echo
echo "pi-extensions installer"
echo "Repository: $SCRIPT_DIR"
echo
require_pi_installed
build_install_set
if [[ -z "$INSTALL_SET" ]]; then
warn "No extensions selected — nothing to install."
exit 0
fi
echo "==> Extensions to symlink into ${EXTENSIONS_DEST}/:"
for n in $INSTALL_SET; do
printf ' %s.ts\n' "$n"
done
echo
confirm || { echo "Aborted."; exit 0; }
echo
for src in "${EXTENSIONS_SRC}"/*.ts; do
[[ -e "$src" ]] || continue
local name
name="$(basename "$src")"
local bare="${name%.ts}"
in_install_set "$bare" || continue
local dest="${EXTENSIONS_DEST}/${name}"
local disabled="${dest}.off"
note "Linking ${name}"
# Respect a prior /ext disable: if <name>.ts.off exists and points
# into this repo, leave it alone. ext-toggle will flip it back.
if [[ -L "$disabled" ]] && link_into_repo "$disabled"; then
ok "${name} kept disabled (${name}.off present)"
continue
fi
if [[ -e "$dest" || -L "$dest" ]]; then
if link_into_repo "$dest"; then
ok "${name} already linked"
continue
fi
local backup="${dest}.bak.$(date +%Y%m%d-%H%M%S)"
mv "$dest" "$backup"
warn "Existing ${dest} backed up to ${backup}"
fi
ln -s "$src" "$dest"
ok "Linked ${name} → ${src}"
done
echo
activate_hooks
echo
ok "Done. Reload pi with /reload or restart to pick up new extensions."
}
# ── commit/push hooks ────────────────────────────────
# core.hooksPath is per-clone git CONFIG and cannot be tracked, so a fresh clone
# has no hooks until something sets it. This is that something.
#
# Deliberately NOT undone by --uninstall: uninstall means "stop managing my pi
# extensions", and silently removing a safety gate as a side effect of that would
# be a surprise in the wrong direction. Unset it by hand if you want it gone.
activate_hooks() {
[[ -d "${SCRIPT_DIR}/hooks" ]] || return 0
# Hooks are for clones you COMMIT from. A vendored copy (pi-devbox bakes
# this repo root-owned under /opt and runs install.sh as an unprivileged
# user on every boot) is not one: git refuses the repo as "dubious
# ownership", `config --local` fails, and the unguarded `git config` below
# then aborted the whole installer under set -e -- one WARN per boot, on
# every device, for a step that had nothing to do there. Skip, don't fail.
local gitdir
if ! gitdir="$(git -C "$SCRIPT_DIR" rev-parse --absolute-git-dir 2>/dev/null)" \
|| [[ ! -w "$gitdir/config" ]]; then
note "Repo hooks skipped: ${SCRIPT_DIR} is not a git clone this user can configure (vendored/read-only copy)"
return 0
fi
local cur
cur="$(git -C "$SCRIPT_DIR" config --local core.hooksPath 2>/dev/null || true)"
if [[ -z "$cur" ]]; then
git -C "$SCRIPT_DIR" config core.hooksPath hooks
ok "Activated repo hooks (core.hooksPath=hooks)"
ok " pre-push warns when a skill/ change needs mirroring into skillset"
elif [[ "$cur" == "hooks" ]]; then
ok "Repo hooks already active (core.hooksPath=hooks)"
else
warn "core.hooksPath is '$cur', leaving it alone -- the skill-mirror"
warn "pre-push reminder will not run. Copy hooks/pre-push into '$cur' to keep it."
fi
}
# ── uninstall ────────────────────────────────────────
do_uninstall() {
echo
echo "pi-extensions uninstaller"
echo "Repository: $SCRIPT_DIR"
echo
confirm || { echo "Aborted."; exit 0; }
echo
local removed=0
# Match both active (.ts) and disabled (.ts.off) symlinks — the ext-toggle
# extension can rename a link to .ts.off to disable it, and uninstall
# should still clean those up.
for pattern in "*.ts" "*.ts.off"; do
for dest in "${EXTENSIONS_DEST}"/$pattern; do
[[ -e "$dest" || -L "$dest" ]] || continue
if link_into_repo "$dest"; then
rm "$dest"
ok "Removed $(basename "$dest")"
(( removed++ )) || true
fi
done
done
if [[ $removed -eq 0 ]]; then
ok "No symlinks pointing into this repo found — nothing removed."
else
echo
ok "Done. Removed ${removed} symlink(s)."
fi
}
case "$ACTION" in
install) do_install ;;
uninstall) do_uninstall ;;
esac