#!/usr/bin/env bash set -euo pipefail # Installs git hook blocks for validation into a target repository. # Usage: setup-hooks.sh [TARGET_REPO] # TARGET_REPO — path to the git repo to configure (default: current directory) # Idempotent: safe to re-run; always replaces each managed block with the current version. SHELLCHECK_VERSION="0.10.0" JQ_VERSION="1.7.1" YQ_VERSION="4.44.3" TOOL_INSTALL_DIR="${TOOL_INSTALL_DIR:-/usr/local/bin}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET="${1:-$(pwd)}" MARKER="# managed by setup-hooks.sh" END_MARKER="# end setup-hooks" if [[ ! -d "$TARGET/.git" ]]; then echo "Error: $TARGET is not a git repository" >&2 exit 1 fi # --- Tool installation --- _os() { case "$(uname -s)" in Linux) echo "linux" ;; Darwin) echo "darwin" ;; *) echo "Error: unsupported OS '$(uname -s)'" >&2; exit 1 ;; esac } _arch() { case "$(uname -m)" in x86_64) echo "x86_64" ;; aarch64 | arm64) echo "aarch64" ;; *) echo "Error: unsupported architecture '$(uname -m)'" >&2; exit 1 ;; esac } install_shellcheck() { local os arch tarball url tmp_dir os="$(_os)" arch="$(_arch)" tarball="shellcheck-v${SHELLCHECK_VERSION}.${os}.${arch}.tar.xz" url="https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/${tarball}" tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' RETURN echo "Installing shellcheck v${SHELLCHECK_VERSION}..." curl -fsSL "$url" -o "$tmp_dir/$tarball" tar -xJf "$tmp_dir/$tarball" -C "$tmp_dir" --strip-components=1 install -m 755 "$tmp_dir/shellcheck" "$TOOL_INSTALL_DIR/shellcheck" echo "Installed: $TOOL_INSTALL_DIR/shellcheck" } install_jq() { local os arch binary url tmp_dir os="$(_os)" arch="$(_arch | sed 's/x86_64/amd64/; s/aarch64/arm64/')" [[ "$(_os)" == "darwin" ]] && os="macos" binary="jq-${os}-${arch}" url="https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/${binary}" tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' RETURN echo "Installing jq v${JQ_VERSION}..." curl -fsSL "$url" -o "$tmp_dir/jq" install -m 755 "$tmp_dir/jq" "$TOOL_INSTALL_DIR/jq" echo "Installed: $TOOL_INSTALL_DIR/jq" } install_yq() { local os arch binary url tmp_dir os="$(_os)" arch="$(_arch | sed 's/x86_64/amd64/; s/aarch64/arm64/')" binary="yq_${os}_${arch}" url="https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/${binary}" tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' RETURN echo "Installing yq v${YQ_VERSION}..." curl -fsSL "$url" -o "$tmp_dir/yq" install -m 755 "$tmp_dir/yq" "$TOOL_INSTALL_DIR/yq" echo "Installed: $TOOL_INSTALL_DIR/yq" } ensure_tool() { local tool="$1" if ! command -v "$tool" &>/dev/null; then "install_${tool}" fi } ensure_tool shellcheck ensure_tool jq ensure_tool yq # --- Marker-block helpers --- write_block() { local hook_file="$1" local block_content="$2" if grep -qF "$MARKER" "$hook_file"; then awk -v start="$MARKER" -v end="$END_MARKER" ' $0 == start { skip=1; next } skip && $0 == end { skip=0; next } !skip { print } ' "$hook_file" > "${hook_file}.tmp" && mv "${hook_file}.tmp" "$hook_file" fi printf '\n%s\n%s\n%s\n' "$MARKER" "$block_content" "$END_MARKER" >> "$hook_file" chmod +x "$hook_file" } ensure_hook() { local hook_file="$1" if [[ ! -f "$hook_file" ]]; then printf '#!/usr/bin/env bash\nset -euo pipefail\n' > "$hook_file" chmod +x "$hook_file" fi } # --- commit-msg: conventional commits --- COMMIT_MSG_HOOK="$TARGET/.git/hooks/commit-msg" ensure_hook "$COMMIT_MSG_HOOK" commit_msg_block() { cat <<'BLOCK' msg=$(cat "$1") pattern='^(feat|fix|docs|chore|refactor|test|perf|ci|build|revert)(\(.+\))?!?: .+' if ! echo "$msg" | grep -qE "$pattern"; then echo "ERROR: Commit message must follow Conventional Commits format." >&2 echo " Examples: feat: add login, fix(auth): correct token expiry, chore!: drop python dep" >&2 exit 1 fi BLOCK } write_block "$COMMIT_MSG_HOOK" "$(commit_msg_block)" echo "Updated: $COMMIT_MSG_HOOK (conventional commits check)" # --- pre-commit: shellcheck + jq/yq + SKILL.md frontmatter --- PRE_COMMIT_HOOK="$TARGET/.git/hooks/pre-commit" ensure_hook "$PRE_COMMIT_HOOK" pre_commit_block() { cat <<'BLOCK' staged=$(git diff --cached --name-only --diff-filter=ACM) # shellcheck on staged .sh files if command -v shellcheck &>/dev/null; then while IFS= read -r f; do [[ -f "$f" ]] && shellcheck "$f" done < <(echo "$staged" | grep '\.sh$' || true) else echo "Warning: shellcheck not installed — shell script linting skipped" >&2 fi # jq validation on staged .json files if command -v jq &>/dev/null; then while IFS= read -r f; do [[ -f "$f" ]] && jq . "$f" > /dev/null done < <(echo "$staged" | grep '\.json$' || true) else echo "Warning: jq not installed — JSON validation skipped" >&2 fi # yq validation on staged .yaml/.yml files if command -v yq &>/dev/null; then while IFS= read -r f; do [[ -f "$f" ]] && yq eval '.' "$f" > /dev/null done < <(echo "$staged" | grep -E '\.(yaml|yml)$' || true) else echo "Warning: yq not installed — YAML validation skipped" >&2 fi # SKILL.md frontmatter: must have name: and description: while IFS= read -r f; do if [[ -f "$f" ]]; then if ! grep -q '^name:' "$f" || ! grep -q '^description' "$f"; then echo "ERROR: $f is missing required frontmatter fields (name: and description:)" >&2 exit 1 fi fi done < <(echo "$staged" | grep 'SKILL\.md$' || true) BLOCK } write_block "$PRE_COMMIT_HOOK" "$(pre_commit_block)" echo "Updated: $PRE_COMMIT_HOOK (shellcheck + jq/yq + SKILL.md validation)" # --- pre-push: test suite + manifest cross-reference --- PRE_PUSH_HOOK="$TARGET/.git/hooks/pre-push" ensure_hook "$PRE_PUSH_HOOK" pre_push_block() { local script_dir="$SCRIPT_DIR" cat <