diff --git a/CHANGELOG.md b/CHANGELOG.md index 8678759..fee9f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `scripts/install.sh` — the one-line `curl … | bash` install now works. The installer previously located its hook sources via `${BASH_SOURCE[0]}` relative to its own file, which is empty when the script is read from stdin; under `set -u` this aborted with `BASH_SOURCE[0]: unbound variable` and then failed to find the sibling `hooks/` directory. The installer now copies from a local checkout when present and otherwise downloads the hook scripts from the repo. New `CLAUDE_CONTEXT_TICK_REF` (default `main`) pins the download ref; `CLAUDE_CONTEXT_TICK_RAW_BASE` overrides the base URL (internal mirror / tests). Regression covered by `tests/test_install_piped.sh`. + ### Added +- `tests/test_install_piped.sh` — hermetic regression test that runs the installer via stdin (reproducing the empty-`BASH_SOURCE` `curl | bash` condition) and resolves hook downloads from the local checkout via a `file://` base, so it needs no network. - `scripts/generate-social-preview.py` — Pillow-based generator for the GitHub social preview / Open Graph card. Re-runnable; portable font fallback chain (JetBrains Mono → SF Mono → Menlo → DejaVu). - `assets/social-preview.png` — 1280×640 social preview card. Uploaded to repo Settings → Social preview to replace the default grey link unfurl. - `scripts/demo.sh` — reproducible mechanism demo (~22 sec): first-run injection → silent rerun → fast-forward state → quarter-hour-tick injection. Cross-platform (BSD/GNU `date`); self-cleans state on exit. diff --git a/README.md b/README.md index f441f98..911231e 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,13 @@ State is tracked per-session in `~/.claude/state/time-inject/` with atomic write curl -fsSL https://raw.githubusercontent.com/DoubleNode/claude-context-tick/main/scripts/install.sh | bash ``` -**Security note:** Always review scripts before piping to `bash`. The script simply merges the hook entry into `~/.claude/settings.json`. +By default the installer pulls the hook scripts from `main`. Pin a specific tag or branch with `CLAUDE_CONTEXT_TICK_REF`: + +```bash +CLAUDE_CONTEXT_TICK_REF=v0.2.0 bash -c "$(curl -fsSL https://raw.githubusercontent.com/DoubleNode/claude-context-tick/v0.2.0/scripts/install.sh)" +``` + +**Security note:** Always review scripts before piping to `bash`. When run this way (no local checkout), the installer downloads the two hook scripts (`inject-time-context.sh`, `session-end.sh`) into `~/.claude/hooks/` and merges the hook entries into `~/.claude/settings.json` (backing it up first). It makes no other changes and never touches the network at hook runtime. ### Manual Install diff --git a/scripts/install.sh b/scripts/install.sh index 2ec2c6a..eb590bf 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,20 +6,43 @@ # without clobbering any existing hooks. # # Usage: -# bash scripts/install.sh +# bash scripts/install.sh # from a checkout +# curl -fsSL .../scripts/install.sh | bash # one-liner # CLAUDE_CONTEXT_TICK_DIR=/custom/path bash scripts/install.sh +# CLAUDE_CONTEXT_TICK_REF=v0.2.0 curl ... | bash # pin a ref # -# Dependencies: bash 3.2+, jq, python3 +# Dependencies: bash 3.2+, jq, python3 (plus curl when not run from a checkout) set -euo pipefail # --------------------------------------------------------------------------- # Resolve paths # --------------------------------------------------------------------------- -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -REPO_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" -HOOK_SRC="$REPO_ROOT/hooks/inject-time-context.sh" -SESSION_END_SRC="$REPO_ROOT/hooks/session-end.sh" +# When run from a checked-out repo, ${BASH_SOURCE[0]} points at this file and we +# copy the hook scripts from the sibling hooks/ directory. When piped through +# `curl ... | bash`, the script is read from stdin: BASH_SOURCE is empty and +# there is no local hooks/ dir, so we download the hooks from the repo instead +# (see install_hook below). The :- guard stops `set -u` from aborting on the +# empty BASH_SOURCE in the piped case. +SCRIPT_SOURCE="${BASH_SOURCE[0]:-}" +if [ -n "$SCRIPT_SOURCE" ]; then + SCRIPT_DIR="$( cd "$( dirname "$SCRIPT_SOURCE" )" && pwd )" + REPO_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" + HOOK_SRC="$REPO_ROOT/hooks/inject-time-context.sh" + SESSION_END_SRC="$REPO_ROOT/hooks/session-end.sh" +else + # Piped via stdin (curl | bash): no local checkout to copy from. + SCRIPT_DIR="" + REPO_ROOT="" + HOOK_SRC="" + SESSION_END_SRC="" +fi + +# Base URL for downloading hooks when no local checkout is present. +# CLAUDE_CONTEXT_TICK_REF pins a branch/tag (default: main). +# CLAUDE_CONTEXT_TICK_RAW_BASE overrides the whole base (internal mirror / tests). +CLAUDE_CONTEXT_TICK_REF="${CLAUDE_CONTEXT_TICK_REF:-main}" +RAW_BASE="${CLAUDE_CONTEXT_TICK_RAW_BASE:-https://raw.githubusercontent.com/DoubleNode/claude-context-tick/${CLAUDE_CONTEXT_TICK_REF}}" INSTALL_DIR="${CLAUDE_CONTEXT_TICK_DIR:-$HOME/.claude/hooks}" SETTINGS_FILE="$HOME/.claude/settings.json" @@ -53,32 +76,44 @@ if [ "$preflight_fail" -ne 0 ]; then exit 1 fi -if [ ! -f "$HOOK_SRC" ]; then - echo "ERROR: Hook source not found: $HOOK_SRC" >&2 - echo "Run install.sh from inside the claude-context-tick repository." >&2 - exit 1 -fi - -if [ ! -f "$SESSION_END_SRC" ]; then - echo "ERROR: Hook source not found: $SESSION_END_SRC" >&2 - echo "Run install.sh from inside the claude-context-tick repository." >&2 - exit 1 -fi - # --------------------------------------------------------------------------- -# Install hook script +# Install hook scripts # --------------------------------------------------------------------------- -mkdir -p "$INSTALL_DIR" - -cp "$HOOK_SRC" "$HOOK_DEST" -chmod +x "$HOOK_DEST" - -echo "Installed hook: $HOOK_DEST" +# Copy from the local checkout when available; otherwise download from the repo +# (the curl | bash case, where there is no sibling hooks/ dir). +# $1 = local source path (may be empty / nonexistent) +# $2 = destination path +# $3 = repo-relative path used for the download fallback +install_hook() { + local src="$1" dest="$2" relpath="$3" + if [ -n "$src" ] && [ -f "$src" ]; then + cp "$src" "$dest" + echo "Installed hook (from checkout): $dest" + else + if ! command -v curl >/dev/null 2>&1; then + echo "ERROR: 'curl' is required to download hooks when not running from a checkout." >&2 + echo "Install curl, or clone the repo and run scripts/install.sh from inside it." >&2 + exit 1 + fi + echo "No local checkout — downloading $relpath from $RAW_BASE" + if ! curl -fsSL "$RAW_BASE/$relpath" -o "$dest"; then + echo "ERROR: Failed to download $relpath from $RAW_BASE" >&2 + echo "Check your connection, or clone the repo and run the installer locally." >&2 + exit 1 + fi + if [ ! -s "$dest" ]; then + echo "ERROR: Downloaded $relpath is empty — refusing to install." >&2 + exit 1 + fi + echo "Installed hook (downloaded): $dest" + fi + chmod +x "$dest" +} -cp "$SESSION_END_SRC" "$SESSION_END_DEST" -chmod +x "$SESSION_END_DEST" +mkdir -p "$INSTALL_DIR" -echo "Installed hook: $SESSION_END_DEST" +install_hook "$HOOK_SRC" "$HOOK_DEST" "hooks/inject-time-context.sh" +install_hook "$SESSION_END_SRC" "$SESSION_END_DEST" "hooks/session-end.sh" # --------------------------------------------------------------------------- # Merge into ~/.claude/settings.json diff --git a/tests/test_install_piped.sh b/tests/test_install_piped.sh new file mode 100755 index 0000000..6e8aad4 --- /dev/null +++ b/tests/test_install_piped.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# test_install_piped.sh — verify install.sh works when piped via stdin (curl | bash). +# +# Regression for the "BASH_SOURCE[0]: unbound variable" crash: when the installer +# is read from stdin there is no script file (BASH_SOURCE is empty) and no sibling +# hooks/ dir to copy from. The installer must instead DOWNLOAD the hook scripts. +# +# This test is hermetic (no network): it feeds install.sh on stdin to reproduce +# the empty-BASH_SOURCE condition, and points the download base at the local +# checkout via a file:// URL using CLAUDE_CONTEXT_TICK_RAW_BASE. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +# Standalone sandbox: the runner exports a sandboxed HOME; create one otherwise. +if [[ "${_RUNNER_SANDBOX:-0}" != "1" ]]; then + _TMP_HOME=$(mktemp -d) + export HOME="$_TMP_HOME" + trap 'rm -rf "$_TMP_HOME"' EXIT +fi + +# shellcheck source=tests/lib.sh +source "${REPO_ROOT}/tests/lib.sh" + +# The download path requires curl; skip cleanly if it is unavailable. +if ! command -v curl >/dev/null 2>&1; then + echo "[SKIP] test_install_piped — curl not available" + exit 0 +fi + +HOOK_DEST="${HOME}/.claude/hooks/inject-time-context.sh" +SESSION_END_DEST="${HOME}/.claude/hooks/session-end.sh" +SETTINGS_FILE="${HOME}/.claude/settings.json" + +# Simulate `curl ... | bash`: read the installer from stdin so BASH_SOURCE is +# empty, and resolve hook downloads from the local checkout via file://. +OUTPUT=$(CLAUDE_CONTEXT_TICK_RAW_BASE="file://${REPO_ROOT}" bash < "${REPO_ROOT}/scripts/install.sh" 2>&1) + +# The original failure modes must not reappear. +assert_not_contains "$OUTPUT" "unbound variable" "piped install must not crash on empty BASH_SOURCE" +assert_not_contains "$OUTPUT" "Hook source not found" "piped install must not bail looking for siblings" + +# Both hooks must be installed via the download path. +assert_file_exists "$HOOK_DEST" "inject hook must be installed via download path" +assert_file_exists "$SESSION_END_DEST" "session-end hook must be installed via download path" +assert_file_exists "$SETTINGS_FILE" "settings.json must be created" + +# Installed hook must be non-empty and executable. +[[ -s "$HOOK_DEST" ]] || { echo "ASSERT FAILED: installed hook is empty" >&2; exit 1; } +[[ -x "$HOOK_DEST" ]] || { echo "ASSERT FAILED: installed hook is not executable" >&2; exit 1; } + +# settings.json must register both hooks pointing at the installed scripts. +UPS_CMD=$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command' "$SETTINGS_FILE") +SE_CMD=$(jq -r '.hooks.SessionEnd[0].hooks[0].command' "$SETTINGS_FILE") +assert_contains "$UPS_CMD" "inject-time-context.sh" "UserPromptSubmit must register the inject hook" +assert_contains "$SE_CMD" "session-end.sh" "SessionEnd must register the session-end hook" + +echo "[PASS] test_install_piped"