Constructor Pattern (RULE ZERO). Zero behaviour change, zero flag
drift — all original CLI flags preserved verbatim.
Before: install.sh — 1238 LOC monolith
After: install.sh — 138 LOC dispatcher (sources libs in order)
install/lib-*.sh — 16 cubes, max 183 LOC (lib-menu)
Cubes:
lib-log 21 LOC — logging primitives
lib-backup 63 LOC — rollback trap + BACKUP_PAIRS
lib-profile 115 LOC — MANIFEST.toml profile resolution
lib-args 92 LOC — CLI parsing + --help heredoc
lib-menu 183 LOC — whiptail/dialog/plain-text interactive picker
lib-plan 150 LOC — dry-run --no-execute output
lib-prereqs 91 LOC — hard + soft dependency checks
lib-primitives 131 LOC — primitive copy + MANIFEST drive
lib-rust 114 LOC — cargo workspace build + pre-built support
lib-scaffold 144 LOC — agent/skill/block scaffolding
lib-bridges 31 LOC — project-bridge install
lib-hooks 104 LOC — settings.json jq merge
lib-agents 77 LOC — assembled agent output
lib-skills 23 LOC — skill copy
lib-wizard 20 LOC — sleep-setup wizard invocation
lib-summary 59 LOC — post-install summary
Invariants preserved:
- macOS bash 3.2 compat (no associative arrays, no [[ ]], no ${,,})
- rollback trap wired via setup_backup_trap early in dispatcher
- jq-merge behaviour verbatim in lib-hooks
- scoped Cargo.toml regeneration in lib-rust
Function LOC limits: largest non-heredoc fn 22 LOC (check_soft_prereqs).
Three functions kept >30 LOC because heredoc-dominated (print_help,
print_summary, profile_members); splitting would fragment logical unit.
62 unique function names across cubes, zero duplicates (grep-verified).
bash -n passes on all 17 files. Runtime smoke test deferred to user's
shell (bash-readonly sandbox constraint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
3.6 KiB
Bash
104 lines
3.6 KiB
Bash
# shellcheck shell=bash
|
|
# lib-hooks.sh — hook file copy + settings.json jq-merge.
|
|
#
|
|
# Hooks are logic (not config) → always refreshed, every install.
|
|
# settings.json merge is idempotent: it groups by matcher and unions .hooks
|
|
# by unique command so repeated runs never duplicate entries.
|
|
#
|
|
# Requires: say / warn / err from lib-log.sh.
|
|
# Requires: backup_file from lib-backup.sh.
|
|
# Reads globals: $KIT_DIR, $HOOKS_DIR, $HOME_DIR.
|
|
|
|
# Copy every *.sh hook from the kit into $HOOKS_DIR, +x, with per-file backup.
|
|
install_hooks() {
|
|
say "copying hooks -> $HOOKS_DIR/"
|
|
local hook_count=0 hook_src h
|
|
for hook_src in "$KIT_DIR/hooks/"*.sh; do
|
|
[ -f "$hook_src" ] || continue
|
|
h="$(basename "$hook_src")"
|
|
backup_file "$HOOKS_DIR/$h"
|
|
cp -f "$hook_src" "$HOOKS_DIR/$h"
|
|
chmod +x "$HOOKS_DIR/$h"
|
|
hook_count=$((hook_count+1))
|
|
done
|
|
say " installed $hook_count hook(s)"
|
|
}
|
|
|
|
# Merge settings-snippet.json into ~/.claude/settings.json non-interactively
|
|
# via jq. On first run (no settings.json) we strip _comment and drop in the
|
|
# snippet verbatim. On subsequent runs we group by matcher and dedupe .hooks
|
|
# by command so re-runs are true no-ops.
|
|
# jq-merge snippet into existing target. group_by matcher + dedup by command
|
|
# so re-runs are no-ops. Args: $1=snippet, $2=target.
|
|
_jq_merge_hooks() {
|
|
local snippet="$1" target="$2" tmp
|
|
tmp="$(mktemp "$target.XXXXXX")"
|
|
jq --slurpfile snip "$snippet" '
|
|
. as $orig
|
|
| ($snip[0] | del(._comment)) as $add
|
|
| reduce ($add.hooks | keys[]) as $phase ($orig;
|
|
.hooks[$phase] = (
|
|
((.hooks[$phase] // []) + ($add.hooks[$phase] // []))
|
|
| group_by(.matcher)
|
|
| map({
|
|
matcher: .[0].matcher,
|
|
hooks: (map(.hooks // []) | add | unique_by(.command))
|
|
})
|
|
)
|
|
)
|
|
' "$target" > "$tmp"
|
|
if [ -s "$tmp" ] && jq -e . "$tmp" >/dev/null 2>&1; then
|
|
mv "$tmp" "$target"
|
|
say "merged hooks into $target (idempotent)"
|
|
else
|
|
rm -f "$tmp"
|
|
err "jq-merge produced invalid output; $target unchanged"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
activate_hooks() {
|
|
local snippet="$KIT_DIR/settings-snippet.json"
|
|
local target="$HOME_DIR/.claude/settings.json"
|
|
[ -f "$snippet" ] || { warn "no snippet at $snippet"; return 0; }
|
|
if [ ! -f "$target" ]; then
|
|
local tmp
|
|
tmp="$(mktemp "$target.XXXXXX")"
|
|
jq 'del(._comment)' "$snippet" > "$tmp"
|
|
mv "$tmp" "$target"
|
|
say "created $target from snippet (no prior settings.json)"
|
|
return 0
|
|
fi
|
|
backup_file "$target"
|
|
_jq_merge_hooks "$snippet" "$target"
|
|
}
|
|
|
|
# Flag-or-prompt dispatcher, mirroring the v0.15 behavior:
|
|
# --activate-hooks → always activate, no prompt
|
|
# no existing settings.json → activate silently (drop in snippet)
|
|
# TTY stdin+stdout → interactive [y/N] prompt
|
|
# otherwise → skip (manual-merge hint printed by summary)
|
|
# Sets global DID_ACTIVATE=1 when activation ran + succeeded.
|
|
maybe_activate_hooks() {
|
|
local settings_file="$HOME_DIR/.claude/settings.json"
|
|
DID_ACTIVATE=0
|
|
if [ "$ACTIVATE_HOOKS" = "1" ]; then
|
|
say "activating hooks (--activate-hooks)"
|
|
activate_hooks && DID_ACTIVATE=1
|
|
elif [ ! -f "$settings_file" ]; then
|
|
say "no existing settings.json; installing snippet"
|
|
activate_hooks && DID_ACTIVATE=1
|
|
elif [ -t 0 ] && [ -t 1 ]; then
|
|
if [ "$COLOR" = "1" ]; then
|
|
printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] '
|
|
else
|
|
printf '[install] activate hooks now? [y/N] '
|
|
fi
|
|
local reply
|
|
read -r reply
|
|
case "$reply" in
|
|
y|Y|yes|YES) activate_hooks && DID_ACTIVATE=1 ;;
|
|
*) say "skipping hook activation" ;;
|
|
esac
|
|
fi
|
|
}
|