-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·356 lines (327 loc) · 15.9 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·356 lines (327 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
#!/usr/bin/env bash
# One-line installer:
# curl -fsSL https://raw.githubusercontent.com/ContextLab/claude-skill-compounder/main/install.sh | bash
# Pinned to a release (recommended once a tag exists):
# SKILL_COMPOUNDER_REF=v0.3.0 bash -c "$(curl -fsSL https://raw.githubusercontent.com/ContextLab/claude-skill-compounder/main/install.sh)"
# Or from a clone: ./install.sh
#
# SKILL_COMPOUNDER_REPO_URL overrides where the managed checkout is cloned FROM. The
# default is the public repository and nothing but a test should change it; it exists so
# tests/test_install_sh.py can point this script at a local bare repository and exercise
# --ref/--update/--rollback with no network at all.
#
# THE WHOLE FILE IS ONE BRACE GROUP AND EVERY PATH ENDS IN `exit`, and here that is
# load-bearing rather than housekeeping. `--update` runs `git` against the very checkout
# this script is being read out of, and bash reads a script lazily by byte offset: rewrite
# the file mid-run and bash resumes at its saved offset in whatever the file now holds.
# The brace group forces a single parse before anything runs, and the closing `exit` on
# every path stops bash resuming past it. Both halves are required.
#
# `set -e` is on, so no branch may be written as `[ cond ] && var=1` at statement level:
# when the test fails the list's status is 1 and the shell exits. Every one of those below
# is an explicit `if` for that reason.
set -euo pipefail
{
REPO_URL="${SKILL_COMPOUNDER_REPO_URL:-https://github.com/ContextLab/claude-skill-compounder.git}"
DEFAULT_HOME="$HOME/.claude/skill-compounder-app"
# The checkout this script manages on the user's behalf. A clone the user made themselves
# is theirs, and the two moving flags below refuse to touch it.
MANAGED_HOME="${CLAUDE_SKILL_COMPOUNDER_APP:-$DEFAULT_HOME}"
# What to check out. `main` for now; the README recommends the pinned form once a tag
# exists. A tag is what makes an install reproducible: `main` is whatever was pushed to it
# this morning, so two people running the same command get different code.
REF="${SKILL_COMPOUNDER_REF:-main}"
REF_GIVEN=0
if [ -n "${SKILL_COMPOUNDER_REF:-}" ]; then REF_GIVEN=1; fi
DO_UPDATE=0
if [ "${SKILL_COMPOUNDER_UPDATE:-0}" = "1" ]; then DO_UPDATE=1; fi
DO_ROLLBACK=0
# Same convention uninstall.sh uses, and for the same reason: `curl … | bash` has no
# checkout around it, so the state directory is where the two scripts agree to look.
STATE_DIR="${CLAUDE_SKILL_COMPOUNDER_STATE:-$HOME/.claude/skill-compounder}"
REF_RECORD="install-ref"
# Everything not ours goes on to scripts/setup.py untouched.
PASS=()
usage() {
cat <<'USAGE'
install.sh [--ref <tag|branch|sha>] [--update] [--rollback] [setup.py options...]
--ref <r> check out <r> instead of the default (env: SKILL_COMPOUNDER_REF)
--update fetch and move the managed checkout to --ref (env: SKILL_COMPOUNDER_UPDATE=1)
--rollback return the managed checkout to the ref recorded before the last update
SKILL_COMPOUNDER_REPO_URL overrides the origin cloned from (default: the public
repository). It is there so a test can use a local bare repo; leave it alone otherwise.
A plain re-run re-wires the current checkout and does NOT move it. Every other option is
passed straight through to scripts/setup.py: --uninstall, --claude-dir, --bin-dir,
--state-dir, --no-doctrine, --enable-review, --disable-review.
Session review (a detached `claude -p` call after long sessions) is OFF by default, even
piped through bash with no tty. Turn it on explicitly with --enable-review (or
SKILL_COMPOUNDER_ENABLE_REVIEW=1); --disable-review turns it back off.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--ref)
shift
if [ $# -eq 0 ]; then echo "error: --ref needs a tag, branch or commit." >&2; exit 2; fi
REF="$1"; REF_GIVEN=1 ;;
--ref=*)
REF="${1#--ref=}"; REF_GIVEN=1
if [ -z "$REF" ]; then echo "error: --ref needs a tag, branch or commit." >&2; exit 2; fi ;;
--update) DO_UPDATE=1 ;;
--rollback) DO_ROLLBACK=1 ;;
# Read, not consumed: setup.py still needs it, and so do we, to find the ref record.
--state-dir)
shift
if [ $# -eq 0 ]; then echo "error: --state-dir needs a directory." >&2; exit 2; fi
STATE_DIR="$1"; PASS[${#PASS[@]}]="--state-dir"; PASS[${#PASS[@]}]="$1" ;;
--state-dir=*)
STATE_DIR="${1#--state-dir=}"; PASS[${#PASS[@]}]="$1" ;;
--help|-h)
usage; exit 0 ;;
*)
PASS[${#PASS[@]}]="$1" ;;
esac
shift
done
if [ "$DO_UPDATE" = 1 ] && [ "$DO_ROLLBACK" = 1 ]; then
echo "error: --update and --rollback ask for opposite things. Pick one." >&2
exit 2
fi
# Are we running from inside a clone (script sits next to skill_compounder/)?
SELF="${BASH_SOURCE[0]:-$0}"
APP_HOME=""
if [ -f "$SELF" ]; then
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
if [ -d "$SCRIPT_DIR/skill_compounder" ]; then
APP_HOME="$SCRIPT_DIR"
fi
fi
# A checkout we made is ours to move. One the user made is not, and moving it would
# discard whatever they had checked out there.
MANAGED=0
if [ -z "$APP_HOME" ]; then
MANAGED=1
elif [ "$APP_HOME" = "$MANAGED_HOME" ]; then
MANAGED=1
fi
git_at() { git -C "$APP_HOME" "$@"; }
# The ref as a name a person recognises, falling back to the commit when HEAD is detached
# at something with no name.
current_ref() {
name="$(git_at symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
if [ -z "$name" ]; then
name="$(git_at describe --tags --exact-match HEAD 2>/dev/null || true)"
fi
if [ -z "$name" ]; then
name="$(git_at rev-parse --short HEAD 2>/dev/null || true)"
fi
printf '%s' "$name"
}
current_sha() { git_at rev-parse HEAD 2>/dev/null || true; }
have_ref() {
if git_at rev-parse --verify --quiet "refs/tags/$1^{commit}" >/dev/null 2>&1; then return 0; fi
if git_at rev-parse --verify --quiet "refs/remotes/origin/$1^{commit}" >/dev/null 2>&1; then return 0; fi
git_at rev-parse --verify --quiet "$1^{commit}" >/dev/null 2>&1
}
# A tag, then the remote-tracking branch, then anything else that resolves. The remote
# branch comes before the bare name so a long-lived clone follows origin rather than a
# stale local branch of the same name.
checkout_ref() {
# A managed checkout is never edited by hand, so local changes here mean something went
# wrong before now, and `git checkout` refusing them would otherwise be reported below as
# "$1 is not a tag, branch or commit", which is the wrong diagnosis.
if [ -n "$(git_at status --porcelain 2>/dev/null)" ]; then
echo "error: $APP_HOME has local changes, so it will not be moved." >&2
echo " 'git -C \"$APP_HOME\" status' shows them; a managed checkout should have none." >&2
return 2
fi
if git_at rev-parse --verify --quiet "refs/tags/$1^{commit}" >/dev/null 2>&1; then
git_at checkout --quiet "refs/tags/$1"
elif git_at rev-parse --verify --quiet "refs/remotes/origin/$1^{commit}" >/dev/null 2>&1; then
git_at checkout --quiet -B "$1" "origin/$1"
elif git_at rev-parse --verify --quiet "$1^{commit}" >/dev/null 2>&1; then
git_at checkout --quiet "$1"
else
return 1
fi
}
# The managed clone is shallow, so a tag cut after it was made is not in it. Fetch, and
# unshallow only when the ref is still missing: a full history is a cost, not a default.
#
# The refspecs are spelled out rather than left to the remote's configuration, and that is
# the whole reason `--update` reaches anything. `git clone --depth 1 --branch main` is a
# SINGLE-BRANCH clone: it configures `+refs/heads/main:refs/remotes/origin/main` and
# nothing else, so a plain `git fetch --tags` there returns success having seen no other
# branch and no tag. The first version of this failed exactly that way, reporting "could
# not fetch" for a ref that was sitting on the remote.
fetch_ref() {
if ! git_at fetch --quiet --force origin \
"+refs/heads/*:refs/remotes/origin/*" "+refs/tags/*:refs/tags/*"; then
return 1
fi
if have_ref "$1"; then return 0; fi
git_at fetch --quiet --unshallow --force origin \
"+refs/heads/*:refs/remotes/origin/*" "+refs/tags/*:refs/tags/*" >/dev/null 2>&1 || true
have_ref "$1"
}
# <state>/install-ref, one line for where the checkout is now and one for where it was
# before the last move. install.sh writes it rather than the installer's manifest, because
# skill_compounder/installer.py records what was LINKED and knows nothing about the
# checkout's git state; adding a key there is a change to a file this script does not own.
read_ref_field() { # $1 = current|previous, $2 = 1 for the ref name, 2 for the sha
if [ ! -f "$STATE_DIR/$REF_RECORD" ]; then return 1; fi
awk -v key="$1" -v col="$2" '$1 == key { print (col == 1 ? $2 : $3); found = 1 }
END { exit found ? 0 : 1 }' "$STATE_DIR/$REF_RECORD"
}
write_ref_record() { # $1 ref now, $2 sha now, $3 ref before, $4 sha before
if ! mkdir -p "$STATE_DIR" 2>/dev/null; then
echo "warning: cannot write $STATE_DIR/$REF_RECORD, so --rollback will have nothing" >&2
echo " to read. The install itself is unaffected." >&2
return 0
fi
{
echo "# claude-skill-compounder: which ref the managed checkout is at."
echo "# Written by install.sh. 'install.sh --rollback' reads the previous line."
echo "current $1 $2"
if [ -n "${3:-}" ] || [ -n "${4:-}" ]; then
echo "previous ${3:-?} ${4:-?}"
fi
} > "$STATE_DIR/$REF_RECORD.tmp"
mv "$STATE_DIR/$REF_RECORD.tmp" "$STATE_DIR/$REF_RECORD"
}
keep_previous() { # re-state the record without disturbing the rollback target
write_ref_record "$1" "$2" \
"$(read_ref_field previous 1 2>/dev/null || true)" \
"$(read_ref_field previous 2 2>/dev/null || true)"
}
# Rotate only when the checkout really moved. An update that lands on the commit already
# checked out must not overwrite the rollback target with itself.
record_move() { # $1 = ref before, $2 = sha before
now_ref="$(current_ref)"
now_sha="$(current_sha)"
if [ "$now_sha" = "$2" ]; then
keep_previous "$now_ref" "$now_sha"
else
write_ref_record "$now_ref" "$now_sha" "$1" "$2"
fi
}
if [ "$MANAGED" = 0 ]; then
# Their clone, their checkout. Refuse the two that would move it; the third is a
# selection they already made by running this script from here.
if [ "$DO_UPDATE" = 1 ] || [ "$DO_ROLLBACK" = 1 ]; then
echo "error: --update and --rollback only manage the checkout install.sh made at" >&2
echo " $MANAGED_HOME" >&2
echo " This is your own clone at $APP_HOME, so moving it is yours to do:" >&2
echo " git -C \"$APP_HOME\" fetch --tags && git -C \"$APP_HOME\" checkout $REF" >&2
echo " Then re-run ./install.sh to re-wire it." >&2
exit 2
fi
if [ "$REF_GIVEN" = 1 ]; then
echo "note: --ref/SKILL_COMPOUNDER_REF is ignored here; installing $APP_HOME as it stands." >&2
fi
fi
# Entered both for the curl|bash form (no APP_HOME yet) and when this script is the copy
# living inside the managed checkout itself: found 2026-09-03, `<managed>/install.sh
# --update` exited 0, printed "Installed", and moved nothing, because the gate here read
# only the first case. A user's own clone (MANAGED=0) was refused above and never gets here.
if [ -z "$APP_HOME" ] || [ "$APP_HOME" = "$MANAGED_HOME" ]; then
APP_HOME="$MANAGED_HOME"
if [ -d "$APP_HOME/.git" ]; then
was_ref="$(current_ref)"
was_sha="$(current_sha)"
if [ "$DO_ROLLBACK" = 1 ]; then
prev_ref="$(read_ref_field previous 1 2>/dev/null || true)"
prev_sha="$(read_ref_field previous 2 2>/dev/null || true)"
if [ "$prev_ref" = "?" ]; then prev_ref=""; fi
if [ "$prev_sha" = "?" ]; then prev_sha=""; fi
if [ -z "$prev_ref" ] && [ -z "$prev_sha" ]; then
echo "error: nothing to roll back to." >&2
echo " $STATE_DIR/$REF_RECORD records no previous ref, which means this" >&2
echo " checkout has not been moved by 'install.sh --update' yet." >&2
exit 3
fi
echo "Rolling $APP_HOME back to ${prev_ref:-$prev_sha} ..."
fetch_ref "${prev_sha:-$prev_ref}" >/dev/null 2>&1 || true
# The sha first: it names one commit, and a tag can be moved out from under a name.
if [ -n "$prev_sha" ] && git_at rev-parse --verify --quiet "$prev_sha^{commit}" >/dev/null 2>&1; then
git_at checkout --quiet "$prev_sha"
elif [ -n "$prev_ref" ] && checkout_ref "$prev_ref"; then
:
else
echo "error: ${prev_ref:-$prev_sha} is not in $APP_HOME and could not be fetched." >&2
exit 3
fi
record_move "$was_ref" "$was_sha"
elif [ "$DO_UPDATE" = 1 ]; then
echo "Updating $APP_HOME to $REF ..."
if ! fetch_ref "$REF"; then
echo "error: could not fetch $REF from $REPO_URL." >&2
exit 3
fi
# `checkout_ref "$REF"; cr=$?` is what this said, and under `set -e` -- see the
# header -- a non-zero `checkout_ref` took the shell out on the FIRST statement, so
# `cr` was never read and both branches below were dead code. A dirty managed
# checkout exited 2, not 3, having printed the right message by luck: `checkout_ref`
# echoes before it returns. `|| cr=$?` is the exemption `set -e` honours.
cr=0
checkout_ref "$REF" || cr=$?
if [ "$cr" = 2 ]; then exit 3; fi # dirty tree: checkout_ref already said so
if [ "$cr" != 0 ]; then
echo "error: $REF is not a tag, branch or commit in $APP_HOME." >&2
exit 3
fi
# Recorded here and not one line later, because from the checkout onwards the record
# is the only thing that knows where this checkout came from. There is no `git pull`
# after this on purpose: `checkout_ref` already put the branch at the tip that
# `fetch_ref` just brought down, so a pull can add nothing and can misfire. It did:
# `checkout -B <b> origin/<b>` sets no upstream, so `git pull --ff-only` fell back to
# the remote's default branch, refused a diverging merge, and -- under `set -e` --
# took the script out before this line, leaving the record naming a ref the checkout
# had already left and pointing --rollback at the wrong commit.
record_move "$was_ref" "$was_sha"
else
# A re-run used to `git pull --ff-only` right here, which moved the user's installed
# code as a side effect of asking to re-wire it. The two asks are now separate.
echo "Re-using the existing checkout at $APP_HOME (at $was_ref)."
echo " It is not moved. To upgrade: install.sh --update [--ref <tag>]"
keep_previous "$was_ref" "$was_sha"
fi
else
if [ "$DO_ROLLBACK" = 1 ]; then
echo "error: there is no checkout at $APP_HOME to roll back." >&2
exit 3
fi
echo "Cloning claude-skill-compounder ($REF) into $APP_HOME ..."
TMP_CLONE="$APP_HOME.clone.$$"
if ! git clone --depth 1 --branch "$REF" "$REPO_URL" "$TMP_CLONE" >/dev/null 2>&1; then
# --branch takes a tag or a branch and not a commit, so a sha lands here.
rm -rf "$TMP_CLONE"
git clone --quiet "$REPO_URL" "$TMP_CLONE"
if ! git -C "$TMP_CLONE" checkout --quiet "$REF"; then
rm -rf "$TMP_CLONE"
echo "error: $REF is not a tag, branch or commit in $REPO_URL." >&2
exit 3
fi
fi
mkdir -p "$(dirname "$APP_HOME")"
mv "$TMP_CLONE" "$APP_HOME"
write_ref_record "$(current_ref)" "$(current_sha)" "" ""
fi
fi
if ! command -v jq >/dev/null 2>&1; then
echo "warning: jq is not on PATH. The hooks and status line need it." >&2
echo " install it with: brew install jq (or) apt-get install jq" >&2
fi
PYTHON="$(command -v python3 || true)"
if [ -z "$PYTHON" ]; then
echo "error: python3 is required but not found on PATH." >&2
exit 1
fi
# Not `exec`, so this script gets to finish: the ref record above describes the checkout's
# git state, which is already true whatever setup.py then does with it. The exit status is
# setup.py's, unchanged. `${PASS[@]+…}` rather than a bare `"${PASS[@]}"`: under `set -u`,
# bash 3.2 -- what macOS ships -- treats an empty array expansion as an unbound variable.
status=0
"$PYTHON" "$APP_HOME/scripts/setup.py" ${PASS[@]+"${PASS[@]}"} || status=$?
exit "$status"
}