750b2a89c8
pi has had MCP as a built-in extension (builtin:mcp) since 0.99.0. It reads ~/.pi/agent/mcp.json and adds `pi mcp add|list|login`, a /mcp manager, OAuth, per-tool exposure and resources. An extension that registers /mcp REPLACES the built-in, so with mcp-loader installed pi never read mcp.json and printed "registers command /mcp, so built-in extension mcp was not loaded" on every start. Measured on pi 1.1.0 (pi-devbox v1.11.0, tor-ms22) before removing it: - time to the (auto) footer in a real pty, 7 interleaved runs each: -ne 0.276 s | -ne -e mcp-loader.ts 0.283 s | -ne -e builtin:mcp 0.272 s - with no servers, the same prompt costs 79 input + 7680 cached tokens with the loader, the built-in, or neither - a stdio server (server-everything) connected via `pi mcp list` in 3 s, and its echo tool ran end to end from `pi -p` with direct exposure - no device in the fleet had an `mcp` block, so nothing needs migrating install.sh now prunes links into this repo whose target is gone (.ts and .ts.off), so the stale mcp-loader.ts link on a preserved pi-devbox volume is removed on the first boot of an image without the file. It also warns once if settings.json still carries an `mcp` block. Tested two-sided in a temp HOME: the new script removes the repo link and a stale .ts.off, and keeps a foreign dangling link and a real file. The old script leaves the dangling link. Re-runs are idempotent, and there is no warning without an mcp block. mempalace.ts (mempalace-toolkit) is unaffected: it registers mempalace_* tools itself. Its vendored RemoteMcpClient loses its sync partner; that is handled in mempalace-toolkit.
298 lines
11 KiB
Bash
Executable File
298 lines
11 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
|
|
}
|
|
|
|
# ── stale links ──────────────────────────────────────
|
|
# An extension removed from this repo leaves its symlink behind in
|
|
# ~/.pi/agent/extensions/ — on a pi-devbox volume that link survives every
|
|
# recreate, pointing into an image that no longer has the file. Remove links
|
|
# that point into THIS repo and whose target is gone (.ts and the /ext-disabled
|
|
# .ts.off alike). Links into other repos and real files are never touched.
|
|
prune_stale_links() {
|
|
local dest pattern
|
|
for pattern in "*.ts" "*.ts.off"; do
|
|
for dest in "${EXTENSIONS_DEST}"/$pattern; do
|
|
[[ -L "$dest" ]] || continue
|
|
[[ -e "$dest" ]] && continue # target still exists
|
|
link_into_repo "$dest" || continue # someone else's link
|
|
rm "$dest"
|
|
ok "Removed stale $(basename "$dest") (no longer in this repo)"
|
|
done
|
|
done
|
|
}
|
|
|
|
# mcp-loader.ts was retired in favour of pi's built-in MCP support. Its config
|
|
# lived in settings.json under a top-level `mcp` key, which pi ignores; say so
|
|
# once instead of letting those servers disappear silently.
|
|
warn_retired_mcp_block() {
|
|
local settings="${PI_AGENT_DIR}/settings.json" has=""
|
|
[[ -f "$settings" ]] || return 0
|
|
if command -v jq >/dev/null 2>&1; then
|
|
has=$(jq -r 'if type == "object" and has("mcp") then "yes" else "" end' "$settings" 2>/dev/null || true)
|
|
elif command -v python3 >/dev/null 2>&1; then
|
|
has=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("yes" if isinstance(d, dict) and "mcp" in d else "")' "$settings" 2>/dev/null || true)
|
|
fi
|
|
[[ "$has" == "yes" ]] || return 0
|
|
warn "${settings} has an 'mcp' block, but mcp-loader.ts is retired and pi ignores it."
|
|
warn " Move those servers to ~/.pi/agent/mcp.json (pi's built-in MCP; see README 'MCP servers')."
|
|
}
|
|
|
|
# ── install ──────────────────────────────────────────
|
|
do_install() {
|
|
echo
|
|
echo "pi-extensions installer"
|
|
echo "Repository: $SCRIPT_DIR"
|
|
echo
|
|
|
|
require_pi_installed
|
|
warn_retired_mcp_block
|
|
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
|
|
|
|
prune_stale_links
|
|
|
|
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
|