Skip to content

Commit ebb02ee

Browse files
peterulsteenclaude
andcommitted
PLN-228: fix(install): harden install.sh error surfacing, idempotent messaging, and shell guard
Rework install.sh so `curl | bash` failures produce actionable output, re-runs give meaningful per-item feedback, and non-bash invocations fail fast with guidance. - Surface real `claude` CLI errors by capturing stderr to per-run tempfiles under `${TMPDIR:-/tmp}/closedloop-install.XXXXXX` (cleaned via an EXIT trap) and displaying them through a BSD- and GNU-sed portable `sanitize_stderr` helper (literal ESC via ANSI-C quoting plus POSIX `tr` octal ranges). - Make the marketplace registration idempotent via `claude plugin marketplace list --json` — re-runs print "Marketplace already registered: closedloop-ai" instead of re-invoking `marketplace add`. - Classify each plugin per run by diffing pre/post snapshots from `claude plugin list --json`, printing exactly one of `Installed:`, `Updated: <plugin> (<old> -> <new>)`, or `Already up to date:`, and rendering a three-count summary that preserves the failed count. - Add a POSIX-portable `BASH_VERSION` guard at the very top of the script so `dash install.sh` (and similar) exit cleanly with a single-line guidance message before any bash-only syntax runs. - Promote `jq` from a soft warn-and-continue to a hard preflight requirement with brew/apt install instructions, since the snapshot and idempotency logic depend on it. Verified on macOS (bash 3.2, BSD sed): fresh install, idempotent re-run, bogus marketplace source, invalid plugin ref, synthesized `Updated` version delta, `dash install.sh`, and `jq` hidden from PATH all produce the expected output and exit codes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a7e8049 commit ebb02ee

1 file changed

Lines changed: 86 additions & 23 deletions

File tree

install.sh

Lines changed: 86 additions & 23 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 ───────────────────────────────────────────────────────────────────
@@ -24,12 +35,26 @@ info() { echo -e "${GREEN}[✓]${NC} $1"; }
2435
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
2536
err() { echo -e "${RED}[✗]${NC} $1"; }
2637
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+
}
2746

2847
# ── Constants ────────────────────────────────────────────────────────────────
2948
MARKETPLACE_SOURCE="closedloop-ai/claude-plugins"
3049
MARKETPLACE_NAME="closedloop-ai"
3150
PLUGINS=(bootstrap code code-review judges platform self-learning)
3251

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+
3358
# ── Preflight checks ────────────────────────────────────────────────────────
3459
echo
3560
echo -e "${BOLD}ClosedLoop Claude Plugins Installer${NC}"
@@ -49,8 +74,8 @@ info "Claude Code CLI found: $(claude --version 2>/dev/null || echo 'unknown ver
4974
# Python 3.11+
5075
if command -v python3 &>/dev/null; then
5176
PY_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
52-
PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
53-
PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
77+
PY_MAJOR="${PY_VERSION%%.*}"
78+
PY_MINOR="${PY_VERSION##*.}"
5479
if [[ "$PY_MAJOR" -gt 3 || ( "$PY_MAJOR" -eq 3 && "$PY_MINOR" -ge 11 ) ]]; then
5580
info "Python $PY_VERSION"
5681
else
@@ -61,23 +86,32 @@ else
6186
fi
6287

6388
# jq
64-
if command -v jq &>/dev/null; then
65-
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
6694
else
67-
warn "jq not found — some plugin features require it"
68-
echo " Install: brew install jq (macOS) / apt install jq (Linux)"
95+
info "jq found"
6996
fi
7097

7198
echo
7299

73100
# ── Add marketplace ─────────────────────────────────────────────────────────
74101
step "Registering closedloop-ai marketplace..."
75102

76-
if claude plugin marketplace add "$MARKETPLACE_SOURCE" 2>/dev/null; then
77-
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"
78107
else
79-
# May already be registered — not a fatal error
80-
warn "Marketplace may already be registered (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"
111+
else
112+
warn "Marketplace add failed:"
113+
sanitize_stderr "$WORK_DIR/marketplace_err"
114+
fi
81115
fi
82116

83117
echo
@@ -86,33 +120,62 @@ echo
86120
step "Installing plugins (user scope)..."
87121

88122
INSTALLED=0
123+
UPDATED=0
124+
UP_TO_DATE=0
89125
FAILED=0
90126

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+
91137
for plugin in "${PLUGINS[@]}"; do
92-
PLUGIN_REF="${plugin}@${MARKETPLACE_NAME}"
93-
if claude plugin install "$PLUGIN_REF" --scope user 2>/dev/null; then
94-
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
95159
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"
96164
else
97-
# May already be installed — try to update instead
98-
if claude plugin update "$PLUGIN_REF" --scope user 2>/dev/null; then
99-
info "Updated: $plugin"
100-
INSTALLED=$((INSTALLED + 1))
101-
else
102-
warn "Could not install/update: $plugin"
103-
FAILED=$((FAILED + 1))
104-
fi
165+
UPDATED=$((UPDATED + 1))
166+
info "Updated: $plugin ($pre_ver -> $post_ver)"
105167
fi
106168
done
107169

108170
echo
109171

110172
# ── Summary ──────────────────────────────────────────────────────────────────
173+
TOTAL=$((INSTALLED + UPDATED + UP_TO_DATE + FAILED))
111174
echo "────────────────────────────────────"
112175
if [[ $FAILED -eq 0 ]]; then
113-
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}"
114177
else
115-
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}"
116179
fi
117180

118181
echo

0 commit comments

Comments
 (0)