Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 63 additions & 28 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions tests/test_install_piped.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading