Skip to content

Commit 212f64c

Browse files
peterulsteenclaude
andcommitted
PLN-228: harden install.sh with bash guard, jq hard prereq, idempotent state detection, and BSD-portable stderr sanitizer
Builds on the earlier error-surfacing work to cover the remaining PLN-228 acceptance criteria and fix three bugs uncovered during end-to-end verification on macOS. Scope covered: - AC-001, AC-006: marketplace idempotency via `claude plugin marketplace list --json`; re-run prints "Marketplace already registered: <name>" instead of re-running `add`. - AC-007, AC-010: pre/post snapshots via `claude plugin list --json` classify each plugin as Installed / Updated / Already up to date, so the three states are distinguishable without parsing CLI stdout. - AC-008: summary line now reports all three counts separately while preserving the failed count. - AC-011: POSIX-portable `BASH_VERSION` guard at script top exits cleanly under dash/ash before any bash-only syntax runs. `sh install.sh` on macOS still passes because `/bin/sh` is bash in POSIX mode — documented as a known limitation in the script comments. - AC-012: `jq` promoted from soft warn to hard prereq, with brew/apt install instructions printed before any marketplace or plugin ops. - Per-run `${TMPDIR:-/tmp}/closedloop-install.XXXXXX` work dir cleaned up via EXIT trap for stderr capture and snapshot files. Bugs fixed during verification: - snapshot_version: add `|| true` so an empty grep match in the classification command substitution no longer trips `set -e`. Without this, a fresh install successfully installed all 6 plugins but errexited before printing any per-plugin status or the summary. - UP_TO_DATE branch: emit `info "Already up to date: <plugin>"` so AC-007's third distinct message is actually visible on re-runs; the counter alone didn't satisfy the AC. - sanitize_stderr: replace GNU-sed-only `\x1b` / `\x00-\x08` escapes with a literal ESC via bash ANSI-C quoting plus `tr` octal ranges so the function works under BSD sed (macOS). Previously it errored with "RE error: invalid character range" and silently swallowed the very CLI error AC-002/AC-004/AC-005 require to be shown. Verified end-to-end on macOS (bash 3.2): - Fresh install: 6× "Installed: <plugin>"; summary "6 installed, 0 updated, 0 already up to date". - Idempotent re-run: "Marketplace already registered: closedloop-ai", 6× "Already up to date: <plugin>", summary "0 installed, 0 updated, 6 already up to date". - Bogus MARKETPLACE_SOURCE: underlying `claude` CLI error surfaced via sanitize_stderr (AC-005). - `dash install.sh`: guard fires, exits 1 with the guidance message. - `jq` hidden from PATH: preflight exits 1 with brew/apt instructions before any marketplace/plugin ops. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 351093b commit 212f64c

1 file changed

Lines changed: 88 additions & 34 deletions

File tree

install.sh

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@
1010
# 3. Installs all 6 plugins globally (user scope)
1111
# 4. Auto-update is enabled by default — plugins stay current automatically
1212
#
13+
# NOTE: The BASH_VERSION check below can be bypassed by setting the BASH_VERSION
14+
# env var before invoking under sh/dash (e.g. BASH_VERSION=x sh install.sh).
15+
# This is a known limitation: the guard is a best-effort hint, not a security boundary.
16+
if [ -z "${BASH_VERSION:-}" ]; then
17+
printf 'Error: This script requires bash. Run: bash install.sh\n or: curl -fsSL https://raw.githubusercontent.com/closedloop-ai/claude-plugins/main/install.sh | bash\n' >&2
18+
exit 1
19+
fi
20+
if [[ "${BASH_VERSINFO[0]:-0}" -lt 3 || ("${BASH_VERSINFO[0]:-0}" -eq 3 && "${BASH_VERSINFO[1]:-0}" -lt 2) ]]; then
21+
printf 'Error: Bash 3.2+ required (found %s)\n' "$BASH_VERSION" >&2
22+
exit 1
23+
fi
1324
set -euo pipefail
1425

1526
# ── Colors ───────────────────────────────────────────────────────────────────
@@ -20,17 +31,30 @@ BLUE='\033[0;34m'
2031
BOLD='\033[1m'
2132
NC='\033[0m'
2233

23-
info() { echo -e "${GREEN}[✓]${NC} $1"; }
24-
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
25-
err() { echo -e "${RED}[✗]${NC} $1"; }
26-
step() { echo -e "${BLUE}[→]${NC} ${BOLD}$1${NC}"; }
27-
strip_ansi() { echo "$1" | sed 's/\x1b\[[0-9;]*m//g'; }
34+
info() { echo -e "${GREEN}[✓]${NC} $1"; }
35+
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
36+
err() { echo -e "${RED}[✗]${NC} $1"; }
37+
step() { echo -e "${BLUE}[→]${NC} ${BOLD}$1${NC}"; }
38+
snapshot_version() { grep -m1 "^$2 " "$1" 2>/dev/null | awk '{print $2}' || true; }
39+
sanitize_stderr() {
40+
# Strip ANSI color escapes, then drop non-printable control chars.
41+
# Uses bash ANSI-C quoting for a literal ESC so this works under BSD sed (macOS)
42+
# as well as GNU sed. `tr` with octal ranges is POSIX-portable across both.
43+
local esc=$'\033'
44+
sed "s/${esc}\[[0-9;]*[a-zA-Z]//g" "$1" | tr -d '\000-\010\013-\037\177' >&2
45+
}
2846

2947
# ── Constants ────────────────────────────────────────────────────────────────
3048
MARKETPLACE_SOURCE="closedloop-ai/claude-plugins"
3149
MARKETPLACE_NAME="closedloop-ai"
3250
PLUGINS=(bootstrap code code-review judges platform self-learning)
3351

52+
# ── Per-run working directory ────────────────────────────────────────────────
53+
WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/closedloop-install.XXXXXX")
54+
chmod 700 "$WORK_DIR"
55+
_cleanup() { rm -rf "$WORK_DIR"; }
56+
trap _cleanup EXIT
57+
3458
# ── Preflight checks ────────────────────────────────────────────────────────
3559
echo
3660
echo -e "${BOLD}ClosedLoop Claude Plugins Installer${NC}"
@@ -50,8 +74,8 @@ info "Claude Code CLI found: $(claude --version 2>/dev/null || echo 'unknown ver
5074
# Python 3.11+
5175
if command -v python3 &>/dev/null; then
5276
PY_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
53-
PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
54-
PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
77+
PY_MAJOR="${PY_VERSION%%.*}"
78+
PY_MINOR="${PY_VERSION##*.}"
5579
if [[ "$PY_MAJOR" -gt 3 || ( "$PY_MAJOR" -eq 3 && "$PY_MINOR" -ge 11 ) ]]; then
5680
info "Python $PY_VERSION"
5781
else
@@ -62,26 +86,31 @@ else
6286
fi
6387

6488
# jq
65-
if command -v jq &>/dev/null; then
66-
info "jq found"
89+
if ! command -v jq &>/dev/null; then
90+
err "jq is required but not found."
91+
echo " Install: brew install jq (macOS)"
92+
echo " Install: apt install jq (Debian/Ubuntu)"
93+
exit 1
6794
else
68-
warn "jq not found — some plugin features require it"
69-
echo " Install: brew install jq (macOS) / apt install jq (Linux)"
95+
info "jq found"
7096
fi
7197

7298
echo
7399

74100
# ── Add marketplace ─────────────────────────────────────────────────────────
75101
step "Registering closedloop-ai marketplace..."
76102

77-
if MARKETPLACE_ERR=$(claude plugin marketplace add "$MARKETPLACE_SOURCE" 2>&1); then
78-
info "Marketplace registered: $MARKETPLACE_SOURCE"
103+
_MARKETPLACE_LIST=$(claude plugin marketplace list --json 2>/dev/null)
104+
if [[ -n "$_MARKETPLACE_LIST" ]] \
105+
&& echo "$_MARKETPLACE_LIST" | jq -e --arg name "$MARKETPLACE_NAME" 'any(.name == $name)' &>/dev/null; then
106+
info "Marketplace already registered: $MARKETPLACE_NAME"
79107
else
80-
MARKETPLACE_ERR_CLEAN=$(strip_ansi "$MARKETPLACE_ERR")
81-
if echo "$MARKETPLACE_ERR_CLEAN" | grep -qiE 'already|exists'; then
82-
warn "Marketplace already registered: $MARKETPLACE_SOURCE (continuing)"
108+
[[ -z "$_MARKETPLACE_LIST" ]] && warn "Could not query marketplace list — attempting add anyway"
109+
if claude plugin marketplace add "$MARKETPLACE_SOURCE" 2>"$WORK_DIR/marketplace_err"; then
110+
info "Marketplace registered: $MARKETPLACE_SOURCE"
83111
else
84-
warn "Marketplace registration failed: $MARKETPLACE_ERR_CLEAN"
112+
warn "Marketplace add failed:"
113+
sanitize_stderr "$WORK_DIR/marketplace_err"
85114
fi
86115
fi
87116

@@ -91,37 +120,62 @@ echo
91120
step "Installing plugins (user scope)..."
92121

93122
INSTALLED=0
123+
UPDATED=0
124+
UP_TO_DATE=0
94125
FAILED=0
95126

127+
SNAPSHOT_PRE="$WORK_DIR/snapshot_pre"
128+
SNAPSHOT_POST="$WORK_DIR/snapshot_post"
129+
STDERR_FILE="$WORK_DIR/install_err"
130+
131+
claude plugin list --json 2>/dev/null \
132+
| jq -r '.[] | .id + " " + .version' > "$SNAPSHOT_PRE" 2>/dev/null || true
133+
[[ -s "$SNAPSHOT_PRE" ]] || warn "Could not snapshot installed plugins — state detection will be approximate"
134+
135+
SUCCESSFUL_PLUGINS=()
136+
96137
for plugin in "${PLUGINS[@]}"; do
97-
PLUGIN_REF="${plugin}@${MARKETPLACE_NAME}"
98-
if INSTALL_ERR=$(claude plugin install "$PLUGIN_REF" --scope user 2>&1); then
99-
info "Installed: $plugin"
138+
plugin_ref="${plugin}@${MARKETPLACE_NAME}"
139+
if claude plugin install "$plugin_ref" --scope user 2>"$STDERR_FILE"; then
140+
SUCCESSFUL_PLUGINS+=("$plugin_ref")
141+
# Install failed — may already exist; try update instead
142+
elif claude plugin update "$plugin_ref" --scope user 2>"$STDERR_FILE"; then
143+
SUCCESSFUL_PLUGINS+=("$plugin_ref")
144+
else
145+
[[ -s "$STDERR_FILE" ]] && sanitize_stderr "$STDERR_FILE"
146+
warn "Could not install/update: $plugin"
147+
FAILED=$((FAILED + 1))
148+
fi
149+
done
150+
151+
claude plugin list --json 2>/dev/null \
152+
| jq -r '.[] | .id + " " + .version' > "$SNAPSHOT_POST" 2>/dev/null || true
153+
154+
for plugin_ref in "${SUCCESSFUL_PLUGINS[@]+"${SUCCESSFUL_PLUGINS[@]}"}"; do
155+
plugin="${plugin_ref%@*}"
156+
pre_ver=$(snapshot_version "$SNAPSHOT_PRE" "$plugin_ref")
157+
post_ver=$(snapshot_version "$SNAPSHOT_POST" "$plugin_ref")
158+
if [[ -z "$pre_ver" || -z "$post_ver" ]]; then
100159
INSTALLED=$((INSTALLED + 1))
160+
info "Installed: $plugin"
161+
elif [[ "$pre_ver" == "$post_ver" ]]; then
162+
UP_TO_DATE=$((UP_TO_DATE + 1))
163+
info "Already up to date: $plugin"
101164
else
102-
# May already be installed — try to update instead
103-
if UPDATE_ERR=$(claude plugin update "$PLUGIN_REF" --scope user 2>&1); then
104-
info "Updated: $plugin"
105-
INSTALLED=$((INSTALLED + 1))
106-
else
107-
warn "Could not install/update: $plugin"
108-
INSTALL_ERR_CLEAN=$(strip_ansi "$INSTALL_ERR")
109-
UPDATE_ERR_CLEAN=$(strip_ansi "$UPDATE_ERR")
110-
[[ -n "$INSTALL_ERR_CLEAN" ]] && warn "install: $INSTALL_ERR_CLEAN"
111-
[[ -n "$UPDATE_ERR_CLEAN" ]] && warn "update: $UPDATE_ERR_CLEAN"
112-
FAILED=$((FAILED + 1))
113-
fi
165+
UPDATED=$((UPDATED + 1))
166+
info "Updated: $plugin ($pre_ver -> $post_ver)"
114167
fi
115168
done
116169

117170
echo
118171

119172
# ── Summary ──────────────────────────────────────────────────────────────────
173+
TOTAL=$((INSTALLED + UPDATED + UP_TO_DATE + FAILED))
120174
echo "────────────────────────────────────"
121175
if [[ $FAILED -eq 0 ]]; then
122-
echo -e "${GREEN}${BOLD}All $INSTALLED plugins installed successfully!${NC}"
176+
echo -e "${GREEN}${BOLD}All $TOTAL plugins ready ($INSTALLED installed, $UPDATED updated, $UP_TO_DATE already up to date).${NC}"
123177
else
124-
echo -e "${YELLOW}${BOLD}$INSTALLED installed, $FAILED failed${NC}"
178+
echo -e "${YELLOW}${BOLD}$TOTAL plugins processed: $INSTALLED installed, $UPDATED updated, $UP_TO_DATE already up to date, $FAILED failed.${NC}"
125179
fi
126180

127181
echo

0 commit comments

Comments
 (0)