-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·310 lines (289 loc) · 12.2 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·310 lines (289 loc) · 12.2 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
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
usage: install.sh [--target claude|polytoken|all] [--overwrite]
--target claude install Claude Code config (default)
--target polytoken install Polytoken config
--target all install both targets, independently
--overwrite take recommended settings over your existing values (no prompt)
CLAUDE_CONFIG_OVERWRITE=1 same, via environment
EOF
}
usage_error() {
[ "$#" -gt 0 ] && echo "install.sh: $*" >&2
usage >&2
exit 2
}
# Parse arguments once. `--target` selects the install mode (default: claude);
# `--overwrite` (or CLAUDE_CONFIG_OVERWRITE=1) accepts recommended conflicts.
target=claude
force=0
[ "${CLAUDE_CONFIG_OVERWRITE:-}" = "1" ] && force=1
while [ "$#" -gt 0 ]; do
case "$1" in
--target) [ "$#" -ge 2 ] || usage_error "--target requires a value"; target=$2; shift 2 ;;
--overwrite) force=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage_error "unknown argument: $1" ;;
esac
done
case "$target" in claude|polytoken|all) ;; *) usage_error "unknown target: $target" ;; esac
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
install_claude() {
SRC_DIR="$(cd "$SELF_DIR/home" && pwd)"
DEST="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
TS="$(date +%Y%m%d-%H%M%S)"
FRAG="$SRC_DIR/settings.recommended.json"
SETTINGS="$DEST/settings.json"
echo "Installing Claude config into: $DEST"
mkdir -p "$DEST"
# 1. Copy every file under home/ EXCEPT the settings fragment (merged separately).
while IFS= read -r -d '' src; do
rel="${src#"$SRC_DIR"/}"
[ "$rel" = "settings.recommended.json" ] && continue
dst="$DEST/$rel"
mkdir -p "$(dirname "$dst")"
if [ ! -e "$dst" ]; then
cp "$src" "$dst"
echo " new: $rel"
elif cmp -s "$src" "$dst"; then
echo " unchanged: $rel"
else
mv "$dst" "$dst.bak-$TS"
cp "$src" "$dst"
echo " updated: $rel (previous saved to $rel.bak-$TS)"
fi
done < <(find "$SRC_DIR" -type f -print0)
# 2. Make scripts executable.
for f in statusline.sh bash-guard/hook.sh branch-guard/hook.sh git-safe/hook.sh \
read-once/hook.sh read-once/compact.sh read-once/read-once \
skill-once/hook.sh skill-once/compact.sh \
hooks/no-remote-writes.sh hooks/agent-state.sh \
agent-join/hook.sh; do
[ -f "$DEST/$f" ] && chmod +x "$DEST/$f"
done
# 2b. Migrate: remove deprecated hook entries that were superseded by skill-once/hook.sh.
# The installer only adds hooks (never removes), so installations that accumulated
# the old monolithic skill-once-hook.sh alongside the new package need an explicit
# cleanup pass. This runs before the merge so the final result is already clean.
if command -v jq >/dev/null 2>&1 && [ -f "$SETTINGS" ]; then
migrate_filter='
def cmdkey: gsub("^~"; $home) | gsub("\\$HOME"; $home);
# True if every command in this hook group is a deprecated script.
def is_deprecated:
[ .hooks[]?.command | cmdkey ] | length > 0 and all(
test("/.claude/hooks/skill-once-hook\\.sh$") or
test("/.claude/hooks/skill-once-compact\\.sh$")
);
# True if this PostCompact entry contains skill-once/compact.sh.
def has_skill_once_compact:
[ .hooks[]?.command | cmdkey ] | any(test("skill-once/compact\\.sh$"));
# True if this PostCompact entry has ONLY read-once/compact.sh (no skill-once).
def is_readonce_only:
[ .hooks[]?.command | cmdkey ] | length > 0 and all(test("read-once/compact\\.sh$"));
if .hooks then
.hooks.PreToolUse = [ (.hooks.PreToolUse // [])[] | select(is_deprecated | not) ]
| .hooks.PostCompact = (
(.hooks.PostCompact // []) as $pc
| if ($pc | any(has_skill_once_compact)) then
[ $pc[] | select((is_deprecated or is_readonce_only) | not) ]
else
[ $pc[] | select(is_deprecated | not) ]
end
)
else . end
'
migrated="$(jq -S --arg home "$HOME" "$migrate_filter" "$SETTINGS" 2>/dev/null)" || migrated=""
if [ -n "$migrated" ] && [ "$(jq -S . "$SETTINGS")" != "$migrated" ]; then
cp "$SETTINGS" "$SETTINGS.bak-$TS"
printf '%s\n' "$migrated" > "$SETTINGS"
echo " migrated: removed deprecated skill-once hook entries (previous saved to settings.json.bak-$TS)"
fi
fi
# 3. Merge the settings fragment, one patch at a time. Each individual difference
# between your settings.json and the recommended merge is accepted or declined
# on its own (interactive [y/N]). Additive patches (new keys, new hooks) and
# value conflicts are all offered. Escape hatches keep their old meaning:
# --overwrite / CLAUDE_CONFIG_OVERWRITE accept everything; no readable TTY
# accepts additive patches only and declines conflicts.
#
# recmerge = the "recommended wins, hooks unioned" merge (the old fragwins).
# It is the upper bound of what could change; patches are the diff from yours.
# shellcheck disable=SC2016 # $home/$mine/$frag/$e/$ex/$rec/$p are jq variables
merge_filter='
def cmdkey: sub("^~"; $home);
.[0] as $mine | .[1] as $frag
| ($mine * $frag)
| .hooks = (
reduce (($frag.hooks // {}) | to_entries[]) as $e (($mine.hooks // {});
.[$e.key] as $cur
| ([ ($cur // [])[] | .hooks[]?.command | cmdkey ]) as $have
| .[$e.key] = ( ($cur // [])
+ [ $e.value[] | select( ([.hooks[]?.command | cmdkey] - $have) | length > 0 ) ] ))
)
'
# Enumerate patches: generic leaf/array values (arrays atomic, hooks excluded)
# plus per-entry hook additions. Emitted as NDJSON, sorted by .sortkey for a
# stable prompt order. $e/$r/$f are slurped existing/recmerge/fragment.
# shellcheck disable=SC2016
enum_filter='
def cmdkey: sub("^~"; $home);
$e[0] as $ex | $r[0] as $rec | $f[0] as $frag
| ( [ $rec | paths(type != "object")
| select(all(.[]; type == "string")) # never descend through an array index -> arrays stay atomic
| select(.[0] != "hooks") ] # hooks are handled by the hook branch below
| map(. as $p | {kind:"generic", path:$p, rec:($rec|getpath($p)), your:($ex|getpath($p))})
| map(select(.rec != .your)) # differs: covers both new and conflict
| map(. + {ckind:(if .your == null then "new" else "conflict" end),
sortkey:("1:" + (.path|join(".")))}) ) as $generic
| ( [ ($frag.hooks // {}) | to_entries[]
| .key as $ev
| ([ ($ex.hooks[$ev] // [])[] | .hooks[]?.command | cmdkey ]) as $have
| (.value | to_entries[])
| select( ([.value.hooks[]?.command | cmdkey] - $have) | length > 0 )
| {kind:"hook", event:$ev, idx:.key, entry:.value,
sortkey:("2:" + $ev + ":" + ((.key + 1000)|tostring))} ] ) as $hooks
| ($generic + $hooks) | sort_by(.sortkey) | .[]
'
# Apply accepted patches onto your file: .[0] = existing, .[1] = accepted array.
# shellcheck disable=SC2016
apply_filter='
reduce .[1][] as $p (.[0];
if $p.kind == "hook"
then .hooks[$p.event] = ((.hooks[$p.event] // []) + [$p.entry])
else setpath($p.path; $p.rec) end)
'
if command -v jq >/dev/null 2>&1; then
if [ -f "$SETTINGS" ]; then
work="$(mktemp -d)"
staged="" # in-$DEST staging file, removed on abort
trap 'rm -rf "$work"; [ -n "$staged" ] && rm -f "$staged"' EXIT
existing="$work/existing.json"
recmerge="$work/recmerge.json"
patches="$work/patches.ndjson"
accepted="$work/accepted.ndjson"
jq -S . "$SETTINGS" > "$existing"
jq -S -s --arg home "$HOME" "$merge_filter" "$SETTINGS" "$FRAG" > "$recmerge"
jq -c -n --arg home "$HOME" \
--slurpfile e "$existing" --slurpfile r "$recmerge" --slurpfile f "$FRAG" \
"$enum_filter" > "$patches"
: > "$accepted"
if [ ! -s "$patches" ]; then
echo " settings.json already up to date — no changes."
else
tty_src="${CLAUDE_CONFIG_TTY:-/dev/tty}"
mode="interactive"
if [ "$force" = "1" ]; then
mode="force"
echo " --overwrite / CLAUDE_CONFIG_OVERWRITE set — taking all recommended values."
elif [ ! -r "$tty_src" ]; then
mode="notty"
fi
# Pre-loop advisory: name the conflicting keys (stderr, so it survives a
# stdout redirect). In notty mode conflicts are skipped; say so.
conflicts="$(jq -r -s '[ .[] | select(.ckind == "conflict") | .path | join(".") ] | join(", ")' "$patches")"
if [ -n "$conflicts" ] && [ "$mode" != "force" ]; then
{
echo ""
if [ "$mode" = "notty" ]; then
echo " ⚠ Recommended value(s) differ from yours: $conflicts"
echo " No terminal to prompt at — keeping yours (conflicts skipped, additive changes applied)."
echo " Re-run with --overwrite (or CLAUDE_CONFIG_OVERWRITE=1) to take recommended values."
else
echo " ⚠ Recommended value(s) differ from yours: $conflicts"
fi
echo ""
} >&2
fi
a_conf=0; a_new=0; a_hook=0; declined=0
while IFS= read -r patch; do
kind="$(jq -r '.kind' <<<"$patch")"
ckind="$(jq -r '.ckind // .kind' <<<"$patch")" # new | conflict | hook
accept=0
case "$mode" in
force) accept=1 ;;
notty) [ "$ckind" != "conflict" ] && accept=1 ;; # additive only
interactive)
{
if [ "$kind" = "hook" ]; then
ev="$(jq -r '.event' <<<"$patch")"
cmds="$(jq -r '[.entry.hooks[]?.command] | join(", ")' <<<"$patch")"
echo " + hook on $ev: $cmds"
elif [ "$ckind" = "conflict" ]; then
p="$(jq -r '.path | join(".")' <<<"$patch")"
echo " ~ $p"
echo " yours: $(jq -rc '.your' <<<"$patch")"
echo " recommended: $(jq -rc '.rec' <<<"$patch")"
else
p="$(jq -r '.path | join(".")' <<<"$patch")"
echo " + $p (new) = $(jq -rc '.rec' <<<"$patch")"
fi
printf " apply? [y/N]: "
} >&2
reply=""
read -r reply < "$tty_src" || reply=""
case "$reply" in y | Y) accept=1 ;; *) accept=0 ;; esac
;;
esac
if [ "$accept" = "1" ]; then
printf '%s\n' "$patch" >> "$accepted"
case "$ckind" in
conflict) a_conf=$((a_conf + 1)) ;;
new) a_new=$((a_new + 1)) ;;
hook) a_hook=$((a_hook + 1)) ;;
esac
else
declined=$((declined + 1))
fi
done < "$patches"
jq -s '.' "$accepted" > "$work/accepted-arr.json"
jq -S -s "$apply_filter" "$existing" "$work/accepted-arr.json" > "$work/chosen.json"
chosen="$work/chosen.json"
echo " applied $((a_conf + a_new + a_hook)) ($a_conf conflict, $a_new new, $a_hook hook), declined $declined"
if [ "$(jq -s '.[0] == .[1]' "$chosen" "$existing")" = "true" ]; then
echo " settings.json unchanged — no write."
else
# Stage, back up, then atomically swap — a failure at any step leaves
# settings.json intact and no orphan files.
staged="$SETTINGS.new-$TS"
cp "$chosen" "$staged"
cp "$SETTINGS" "$SETTINGS.bak-$TS"
mv "$staged" "$SETTINGS" # atomic replace (same directory)
staged=""
echo " updated settings.json (previous saved to settings.json.bak-$TS)"
fi
fi
else
cp "$FRAG" "$SETTINGS"
echo " wrote new settings.json from the recommended fragment"
fi
else
echo " jq not found — skipping settings merge."
echo " Manually merge keys from: $FRAG"
echo " into: $SETTINGS (do NOT overwrite your permissions block)"
fi
echo "Done."
}
case "$target" in
claude)
install_claude
;;
polytoken)
bash "$SELF_DIR/scripts/install-polytoken.sh" "$force"
;;
all)
# Run both targets independently; report each result and exit nonzero if
# either failed. A success is not rolled back when the other target fails.
rc=0
echo "==> target claude"
if install_claude; then echo "target claude: OK"; else echo "target claude: FAILED"; rc=1; fi
echo "==> target polytoken"
if bash "$SELF_DIR/scripts/install-polytoken.sh" "$force"; then
echo "target polytoken: OK"
else
echo "target polytoken: FAILED"; rc=1
fi
exit "$rc"
;;
esac