From 44d5c1ffdfc7dbe97487ec3d93f13fe85e170fca Mon Sep 17 00:00:00 2001 From: Joseph Fung Date: Sat, 25 Apr 2026 16:30:23 -0400 Subject: [PATCH 1/2] feat: periodic update check with TTL cache and auto-snooze Adds a UserPromptSubmit hook that checks for newer trimkit versions once per 24 hours. Fetches package.json from the main branch on GitHub, compares against the local version, and prints a one-line notice when an update is available. Auto-snoozes per-version for 7 days after notifying; silent on fetch failures. New files: - package.json (version source of truth) - bin/trimkit-update-check (hook script: TTL cache, fetch, compare, snooze) - bin/trimkit-update-snooze (writes snoozed-version + snoozed-until) - tests/bin/trimkit-update-check.bats - tests/bin/trimkit-update-snooze.bats Modified: - settings/hooks.json: add UserPromptSubmit hook entry - install.sh: write ~/.trimkit/install-dir so the check can find package.json - .github/workflows/test.yml: include tests/bin/ in BATS run - CHANGELOG.md: document the new feature State files live under ~/.trimkit/update-check/. All env vars are overrideable for testing (TRIMKIT_UPDATE_STATE_DIR, TRIMKIT_UPDATE_INSTALL_DIR, TRIMKIT_UPDATE_REMOTE_URL, TRIMKIT_UPDATE_TTL, TRIMKIT_UPDATE_SNOOZE_DAYS). Closes #8 --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 6 + bin/trimkit-update-check | 146 +++++++++++++++ bin/trimkit-update-snooze | 48 +++++ install.sh | 11 ++ package.json | 5 + settings/hooks.json | 12 ++ tests/bin/trimkit-update-check.bats | 265 +++++++++++++++++++++++++++ tests/bin/trimkit-update-snooze.bats | 119 ++++++++++++ 9 files changed, 613 insertions(+), 1 deletion(-) create mode 100755 bin/trimkit-update-check create mode 100755 bin/trimkit-update-snooze create mode 100644 package.json create mode 100644 tests/bin/trimkit-update-check.bats create mode 100644 tests/bin/trimkit-update-snooze.bats diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6616b7f..751e7b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,4 +18,4 @@ jobs: run: sudo apt-get install -y jq - name: Run tests - run: ./tests/test_helper/bats-core/bin/bats tests/hooks/ tests/install/ + run: ./tests/test_helper/bats-core/bin/bats tests/hooks/ tests/install/ tests/bin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb7e3f..840fda8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,15 @@ All notable changes to TrimKit are documented here. - `prod-debug` skill — pre-loads DB schema and container registry for production debugging sessions - Sysops audit log — `/sysops log` command; appends session/project-stamped entries to a JSONL file +### Update check +- Periodic update notifications — TTL-cached (24h) check against `main` branch on GitHub; silent when up-to-date or on fetch failure; auto-snoozes per-version for 7 days after notifying +- `trimkit-update-check` — bin script called as a `UserPromptSubmit` hook on each Claude Code session +- `trimkit-update-snooze` — bin script that writes snooze state; also callable manually + ### Installer - Plugin bootstrapping via `plugins/plugins.txt` - `--upgrade` flag to replace existing real files with symlinks (backs up originals) - Hooks auto-merged into `~/.claude/settings.json` on install - CLAUDE.md injection — writes TrimKit hook compatibility tips into `~/.claude/CLAUDE.md` - Misc quality-of-life: interactive PATH prompt, improved install summary output +- Writes `~/.trimkit/install-dir` so `trimkit-update-check` can locate the local `package.json` diff --git a/bin/trimkit-update-check b/bin/trimkit-update-check new file mode 100755 index 0000000..b84a071 --- /dev/null +++ b/bin/trimkit-update-check @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# trimkit-update-check — Check for available trimkit updates. +# +# Runs at most once per 24 hours (TTL-cached). Fetches the latest version +# from GitHub and compares against the locally installed version. Prints a +# one-line notice if an update is available; silent otherwise. +# +# Called as a UserPromptSubmit hook on every Claude Code prompt submission. +# Always exits 0 — failure must never block Claude Code. +# +# State files (all under STATE_DIR): +# last-checked ISO 8601 timestamp of last remote fetch +# latest-version cached version string from GitHub +# snoozed-version version string that is currently snoozed +# snoozed-until ISO 8601 timestamp when snooze expires +# +# Environment overrides (for testing): +# TRIMKIT_UPDATE_STATE_DIR state directory (default: ~/.trimkit/update-check) +# TRIMKIT_UPDATE_INSTALL_DIR trimkit install dir (default: read from ~/.trimkit/install-dir) +# TRIMKIT_UPDATE_REMOTE_URL remote package.json URL +# TRIMKIT_UPDATE_TTL fetch TTL in seconds (default: 86400) +set -euo pipefail + +STATE_DIR="${TRIMKIT_UPDATE_STATE_DIR:-${HOME}/.trimkit/update-check}" +REMOTE_URL="${TRIMKIT_UPDATE_REMOTE_URL:-https://raw.githubusercontent.com/josephfung/trimkit/main/package.json}" +TTL="${TRIMKIT_UPDATE_TTL:-86400}" + +# All logic is wrapped in run_check so we can safely 'return' on any error +# without affecting the outer exit 0. +run_check() { + local install_dir pkg_json local_version + local ttl_expired last_checked now remote_json latest_version + local is_newer snoozed_version snoozed_until + + # Resolve the trimkit install directory + if [ -n "${TRIMKIT_UPDATE_INSTALL_DIR:-}" ]; then + install_dir="$TRIMKIT_UPDATE_INSTALL_DIR" + elif [ -f "${HOME}/.trimkit/install-dir" ]; then + install_dir="$(cat "${HOME}/.trimkit/install-dir")" + else + # Not installed via install.sh — skip check silently + return 0 + fi + + # Read local version from the trimkit clone's package.json + pkg_json="$install_dir/package.json" + [ -f "$pkg_json" ] || return 0 + local_version="$(python3 -c " +import json, sys +print(json.load(open(sys.argv[1]))['version']) +" "$pkg_json" 2>/dev/null)" || return 0 + [ -n "$local_version" ] || return 0 + + # Ensure state directory exists + mkdir -p "$STATE_DIR" 2>/dev/null || return 0 + + # Check whether the last-checked timestamp is within TTL + ttl_expired=1 + if [ -f "$STATE_DIR/last-checked" ]; then + last_checked="$(cat "$STATE_DIR/last-checked" 2>/dev/null)" + if [ -n "$last_checked" ]; then + python3 -c " +from datetime import datetime, timezone +import sys +last = datetime.fromisoformat(sys.argv[1].replace('Z', '+00:00')) +now_dt = datetime.now(timezone.utc) +sys.exit(0 if (now_dt - last).total_seconds() < float(sys.argv[2]) else 1) +" "$last_checked" "$TTL" 2>/dev/null && ttl_expired=0 + fi + fi + + # Fetch the remote version if the TTL has expired + if [ "$ttl_expired" -eq 1 ]; then + # Compute current timestamp lazily — only needed here for writing last-checked + now="$(python3 -c " +from datetime import datetime, timezone +print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) +" 2>/dev/null)" || return 0 + + # Update last-checked before the fetch so a network failure still backs off + # for the full TTL — avoids a 3-second curl timeout on every prompt when the + # network is flaky. + printf '%s\n' "$now" > "$STATE_DIR/last-checked.tmp" \ + && mv "$STATE_DIR/last-checked.tmp" "$STATE_DIR/last-checked" \ + || return 0 + + remote_json="$(curl -sf --max-time 3 "$REMOTE_URL" 2>/dev/null)" || return 0 + + latest_version="$(printf '%s' "$remote_json" | python3 -c " +import json, sys +print(json.load(sys.stdin)['version']) +" 2>/dev/null)" || return 0 + [ -n "$latest_version" ] || return 0 + + printf '%s\n' "$latest_version" > "$STATE_DIR/latest-version.tmp" \ + && mv "$STATE_DIR/latest-version.tmp" "$STATE_DIR/latest-version" \ + || return 0 + fi + + # Read the cached latest version (may have just been written above) + [ -f "$STATE_DIR/latest-version" ] || return 0 + latest_version="$(cat "$STATE_DIR/latest-version" 2>/dev/null)" + [ -n "$latest_version" ] || return 0 + + # Compare versions using tuple comparison — is latest strictly newer than local? + # int() is guarded with try/except to handle pre-release labels (e.g. 1.0.0-rc.1). + is_newer="$(python3 -c " +import sys +def parse(v): + parts = [] + for x in v.strip().split('.')[:3]: + try: + parts.append(int(x)) + except ValueError: + parts.append(0) + return tuple(parts) +print('1' if parse(sys.argv[2]) > parse(sys.argv[1]) else '0') +" "$local_version" "$latest_version" 2>/dev/null)" || return 0 + [ "$is_newer" = "1" ] || return 0 + + # Check snooze — suppress the notice if this version is still snoozed + if [ -f "$STATE_DIR/snoozed-version" ] && [ -f "$STATE_DIR/snoozed-until" ]; then + snoozed_version="$(cat "$STATE_DIR/snoozed-version" 2>/dev/null)" + snoozed_until="$(cat "$STATE_DIR/snoozed-until" 2>/dev/null)" + if [ "$snoozed_version" = "$latest_version" ] && [ -n "$snoozed_until" ]; then + python3 -c " +from datetime import datetime, timezone +import sys +until = datetime.fromisoformat(sys.argv[1].replace('Z', '+00:00')) +sys.exit(0 if datetime.now(timezone.utc) < until else 1) +" "$snoozed_until" 2>/dev/null && return 0 + fi + fi + + # Print the one-line update notice using the actual install path + printf 'trimkit v%s is available (you have v%s). Run: cd %s; git pull; ./install.sh\n' \ + "$latest_version" "$local_version" "$install_dir" + + # Auto-snooze via the dedicated script to avoid repeating on every subsequent + # prompt until the next TTL expiry. Best-effort — if it fails the notice + # recurs after the TTL (24 h), not on every message. + "$(dirname "${BASH_SOURCE[0]}")/trimkit-update-snooze" "$latest_version" 2>/dev/null || true +} + +run_check +exit 0 diff --git a/bin/trimkit-update-snooze b/bin/trimkit-update-snooze new file mode 100755 index 0000000..e45a4ae --- /dev/null +++ b/bin/trimkit-update-snooze @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# trimkit-update-snooze — Snooze trimkit update notifications for a version. +# +# Usage: trimkit-update-snooze +# +# Writes snoozed-version and snoozed-until into the update-check state dir. +# trimkit-update-check calls this automatically after showing a notice, but +# it can also be run manually to snooze a specific version. +# +# Environment overrides (for testing): +# TRIMKIT_UPDATE_STATE_DIR state directory (default: ~/.trimkit/update-check) +# TRIMKIT_UPDATE_SNOOZE_DAYS snooze duration in days (default: 7) +set -euo pipefail + +STATE_DIR="${TRIMKIT_UPDATE_STATE_DIR:-${HOME}/.trimkit/update-check}" +SNOOZE_DAYS="${TRIMKIT_UPDATE_SNOOZE_DAYS:-7}" + +if [ $# -lt 1 ]; then + echo "trimkit-update-snooze: error: missing version argument" >&2 + echo "Usage: trimkit-update-snooze " >&2 + exit 1 +fi + +version="$1" + +mkdir -p "$STATE_DIR" || { + echo "trimkit-update-snooze: error: cannot create state directory '$STATE_DIR'" >&2 + exit 1 +} + +snooze_until="$(python3 -c " +from datetime import datetime, timezone, timedelta +import sys +until = datetime.now(timezone.utc) + timedelta(days=int(sys.argv[1])) +print(until.strftime('%Y-%m-%dT%H:%M:%SZ')) +" "$SNOOZE_DAYS")" + +printf '%s\n' "$version" > "$STATE_DIR/snoozed-version.tmp" \ + && mv "$STATE_DIR/snoozed-version.tmp" "$STATE_DIR/snoozed-version" || { + echo "trimkit-update-snooze: error: cannot write snoozed-version" >&2 + exit 1 +} + +printf '%s\n' "$snooze_until" > "$STATE_DIR/snoozed-until.tmp" \ + && mv "$STATE_DIR/snoozed-until.tmp" "$STATE_DIR/snoozed-until" || { + echo "trimkit-update-snooze: error: cannot write snoozed-until" >&2 + exit 1 +} diff --git a/install.sh b/install.sh index 2a4909b..63cc000 100755 --- a/install.sh +++ b/install.sh @@ -174,6 +174,17 @@ symlink_dirs "$SCRIPT_DIR/skills" "$HOME/.claude/skills" _installed_before_bin=${#installed[@]} symlink_files "$SCRIPT_DIR/bin" "$HOME/.trimkit/bin" +# --------------------------------------------------------------------------- +# Install directory record (used by trimkit-update-check to find package.json) +# --------------------------------------------------------------------------- + +# Write the absolute path of this trimkit clone to ~/.trimkit/install-dir so +# trimkit-update-check can locate the local package.json without resolving +# symlinks (which is not portable across macOS and Linux). +printf '%s\n' "$SCRIPT_DIR" > "$HOME/.trimkit/install-dir.tmp" \ + && mv "$HOME/.trimkit/install-dir.tmp" "$HOME/.trimkit/install-dir" \ + || echo "Warning: could not write ~/.trimkit/install-dir — update checks will be skipped" >&2 + # --------------------------------------------------------------------------- # Plugins # --------------------------------------------------------------------------- diff --git a/package.json b/package.json new file mode 100644 index 0000000..b7242bc --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "trimkit", + "version": "0.5.0", + "description": "Claude Code guardrails and utilities" +} diff --git a/settings/hooks.json b/settings/hooks.json index f57fb54..98da105 100644 --- a/settings/hooks.json +++ b/settings/hooks.json @@ -1,6 +1,18 @@ { "_comment": "Merge this into your ~/.claude/settings.json under hooks", "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "~/.trimkit/bin/trimkit-update-check", + "statusMessage": "Checking for trimkit updates..." + } + ] + } + ], "PreToolUse": [ { "matcher": "Bash", diff --git a/tests/bin/trimkit-update-check.bats b/tests/bin/trimkit-update-check.bats new file mode 100644 index 0000000..87ecd38 --- /dev/null +++ b/tests/bin/trimkit-update-check.bats @@ -0,0 +1,265 @@ +#!/usr/bin/env bats + +# tests/bin/trimkit-update-check.bats — tests for bin/trimkit-update-check + +setup() { + load '../test_helper/bats-support/load' + load '../test_helper/bats-assert/load' + + SCRIPT="$BATS_TEST_DIRNAME/../../bin/trimkit-update-check" + + FAKE_HOME="$(mktemp -d)" + FAKE_STATE_DIR="$FAKE_HOME/update-check" + FAKE_INSTALL_DIR="$FAKE_HOME/trimkit" + MOCK_BIN="$(mktemp -d)" + MOCK_CURL_CALLS="$FAKE_HOME/curl-calls" + MOCK_REMOTE_JSON="$FAKE_HOME/remote.json" + + # Local install: version 0.5.0 + mkdir -p "$FAKE_INSTALL_DIR" + printf '{"name":"trimkit","version":"0.5.0"}\n' > "$FAKE_INSTALL_DIR/package.json" + + # Remote: same version by default (no update available) + printf '{"name":"trimkit","version":"0.5.0"}\n' > "$MOCK_REMOTE_JSON" + + # Call-count file — starts empty; mock curl appends one line per call + touch "$MOCK_CURL_CALLS" + + # Mock curl — outputs $MOCK_CURL_RESPONSE, records each call, honours $MOCK_CURL_EXIT + cat > "$MOCK_BIN/curl" <<'MOCKCURL' +#!/bin/bash +printf '1\n' >> "${MOCK_CURL_CALLS}" +if [ "${MOCK_CURL_EXIT:-0}" != "0" ]; then + exit "${MOCK_CURL_EXIT}" +fi +cat "${MOCK_CURL_RESPONSE}" +MOCKCURL + chmod +x "$MOCK_BIN/curl" + + export PATH="$MOCK_BIN:$PATH" + export TRIMKIT_UPDATE_STATE_DIR="$FAKE_STATE_DIR" + export TRIMKIT_UPDATE_INSTALL_DIR="$FAKE_INSTALL_DIR" + export TRIMKIT_UPDATE_REMOTE_URL="https://fake.example.com/package.json" + export TRIMKIT_UPDATE_TTL="86400" + export TRIMKIT_UPDATE_SNOOZE_DAYS="7" + export MOCK_CURL_CALLS + export MOCK_CURL_RESPONSE="$MOCK_REMOTE_JSON" + unset MOCK_CURL_EXIT +} + +teardown() { + rm -rf "$FAKE_HOME" "$MOCK_BIN" +} + +# --------------------------------------------------------------------------- +# Basic contract +# --------------------------------------------------------------------------- + +@test "script exists and is executable" { + [ -x "$SCRIPT" ] +} + +@test "always exits 0" { + run bash "$SCRIPT" + assert_success +} + +# --------------------------------------------------------------------------- +# Install-dir detection +# --------------------------------------------------------------------------- + +@test "silent when TRIMKIT_UPDATE_INSTALL_DIR is unset and no install-dir file" { + unset TRIMKIT_UPDATE_INSTALL_DIR + run bash "$SCRIPT" + assert_success + assert_output '' +} + +# --------------------------------------------------------------------------- +# Version comparison +# --------------------------------------------------------------------------- + +@test "silent when remote version equals local" { + # Default setup: both 0.5.0 + run bash "$SCRIPT" + assert_success + assert_output '' +} + +@test "prints notice when remote is newer" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + run bash "$SCRIPT" + assert_success + assert_output --partial 'trimkit v1.0.0 is available' + assert_output --partial 'you have v0.5.0' +} + +@test "notice includes update command and actual install dir" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + run bash "$SCRIPT" + assert_output --partial 'git pull' + assert_output --partial 'install.sh' + assert_output --partial "$FAKE_INSTALL_DIR" +} + +@test "silent when remote is older than local" { + printf '{"name":"trimkit","version":"0.4.0"}\n' > "$MOCK_REMOTE_JSON" + run bash "$SCRIPT" + assert_success + assert_output '' +} + +# --------------------------------------------------------------------------- +# TTL cache +# --------------------------------------------------------------------------- + +@test "does not call curl when last-checked is within TTL" { + # Pre-populate cache with current timestamp and a cached version + mkdir -p "$FAKE_STATE_DIR" + python3 -c " +from datetime import datetime, timezone +print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) +" > "$FAKE_STATE_DIR/last-checked" + printf '0.5.0\n' > "$FAKE_STATE_DIR/latest-version" + + run bash "$SCRIPT" + assert_success + + assert_equal "$(wc -l < "$MOCK_CURL_CALLS" | tr -d ' ')" "0" +} + +@test "calls curl when last-checked is absent (first run)" { + run bash "$SCRIPT" + assert_success + assert_equal "$(wc -l < "$MOCK_CURL_CALLS" | tr -d ' ')" "1" +} + +@test "calls curl when last-checked is stale" { + mkdir -p "$FAKE_STATE_DIR" + printf '2000-01-01T00:00:00Z\n' > "$FAKE_STATE_DIR/last-checked" + + run bash "$SCRIPT" + assert_success + assert_equal "$(wc -l < "$MOCK_CURL_CALLS" | tr -d ' ')" "1" +} + +@test "uses cached latest-version to show notice without calling curl" { + # TTL is fresh; cached version is newer + mkdir -p "$FAKE_STATE_DIR" + python3 -c " +from datetime import datetime, timezone +print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) +" > "$FAKE_STATE_DIR/last-checked" + printf '1.0.0\n' > "$FAKE_STATE_DIR/latest-version" + + run bash "$SCRIPT" + assert_success + assert_output --partial 'trimkit v1.0.0 is available' + assert_equal "$(wc -l < "$MOCK_CURL_CALLS" | tr -d ' ')" "0" +} + +@test "writes last-checked after fetch" { + run bash "$SCRIPT" + assert_success + [ -f "$FAKE_STATE_DIR/last-checked" ] + python3 -c " +from datetime import datetime +ts = open('$FAKE_STATE_DIR/last-checked').read().strip() +datetime.fromisoformat(ts.replace('Z', '+00:00')) # raises if invalid +" +} + +# --------------------------------------------------------------------------- +# Fetch failures +# --------------------------------------------------------------------------- + +@test "silent and exits 0 on fetch failure" { + export MOCK_CURL_EXIT=1 + run bash "$SCRIPT" + assert_success + assert_output '' +} + +@test "updates last-checked even when fetch fails" { + export MOCK_CURL_EXIT=1 + run bash "$SCRIPT" + assert_success + [ -f "$FAKE_STATE_DIR/last-checked" ] +} + +# --------------------------------------------------------------------------- +# Snooze +# --------------------------------------------------------------------------- + +@test "writes auto-snooze files after showing notice" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + run bash "$SCRIPT" + assert_success + [ -f "$FAKE_STATE_DIR/snoozed-version" ] + [ -f "$FAKE_STATE_DIR/snoozed-until" ] + assert_equal "$(cat "$FAKE_STATE_DIR/snoozed-version")" "1.0.0" +} + +@test "silent during active snooze" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + mkdir -p "$FAKE_STATE_DIR" + printf '1.0.0\n' > "$FAKE_STATE_DIR/snoozed-version" + python3 -c " +from datetime import datetime, timezone, timedelta +until = datetime.now(timezone.utc) + timedelta(days=7) +print(until.strftime('%Y-%m-%dT%H:%M:%SZ')) +" > "$FAKE_STATE_DIR/snoozed-until" + + run bash "$SCRIPT" + assert_success + assert_output '' +} + +@test "second run is silent after auto-snooze is written" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + + # First run: notice shown, snooze written + bash "$SCRIPT" + + # Second run: snoozed — must be silent + run bash "$SCRIPT" + assert_success + assert_output '' +} + +@test "shows notice when snooze has expired" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + mkdir -p "$FAKE_STATE_DIR" + printf '1.0.0\n' > "$FAKE_STATE_DIR/snoozed-version" + printf '2000-01-01T00:00:00Z\n' > "$FAKE_STATE_DIR/snoozed-until" + + run bash "$SCRIPT" + assert_success + assert_output --partial 'trimkit v1.0.0 is available' +} + +@test "shows notice when a different (older) version is snoozed" { + printf '{"name":"trimkit","version":"2.0.0"}\n' > "$MOCK_REMOTE_JSON" + mkdir -p "$FAKE_STATE_DIR" + # Snoozed for 1.0.0, but remote is now 2.0.0 + printf '1.0.0\n' > "$FAKE_STATE_DIR/snoozed-version" + python3 -c " +from datetime import datetime, timezone, timedelta +until = datetime.now(timezone.utc) + timedelta(days=7) +print(until.strftime('%Y-%m-%dT%H:%M:%SZ')) +" > "$FAKE_STATE_DIR/snoozed-until" + + run bash "$SCRIPT" + assert_success + assert_output --partial 'trimkit v2.0.0 is available' +} + +# --------------------------------------------------------------------------- +# State directory +# --------------------------------------------------------------------------- + +@test "creates state directory if it does not exist" { + run bash "$SCRIPT" + assert_success + [ -d "$FAKE_STATE_DIR" ] +} diff --git a/tests/bin/trimkit-update-snooze.bats b/tests/bin/trimkit-update-snooze.bats new file mode 100644 index 0000000..8ea0c5e --- /dev/null +++ b/tests/bin/trimkit-update-snooze.bats @@ -0,0 +1,119 @@ +#!/usr/bin/env bats + +# tests/bin/trimkit-update-snooze.bats — tests for bin/trimkit-update-snooze + +setup() { + load '../test_helper/bats-support/load' + load '../test_helper/bats-assert/load' + + SCRIPT="$BATS_TEST_DIRNAME/../../bin/trimkit-update-snooze" + + FAKE_HOME="$(mktemp -d)" + FAKE_STATE_DIR="$FAKE_HOME/update-check" + + export TRIMKIT_UPDATE_STATE_DIR="$FAKE_STATE_DIR" + export TRIMKIT_UPDATE_SNOOZE_DAYS="7" +} + +teardown() { + rm -rf "$FAKE_HOME" +} + +# --------------------------------------------------------------------------- +# Basic contract +# --------------------------------------------------------------------------- + +@test "script exists and is executable" { + [ -x "$SCRIPT" ] +} + +@test "exits non-zero and prints usage on missing version argument" { + run bash "$SCRIPT" + assert_failure + assert_output --partial 'missing version argument' + assert_output --partial 'Usage:' +} + +# --------------------------------------------------------------------------- +# State file content +# --------------------------------------------------------------------------- + +@test "writes correct snoozed-version" { + run bash "$SCRIPT" "1.2.3" + assert_success + [ -f "$FAKE_STATE_DIR/snoozed-version" ] + assert_equal "$(cat "$FAKE_STATE_DIR/snoozed-version")" "1.2.3" +} + +@test "snoozed-version is a single line" { + bash "$SCRIPT" "1.2.3" + assert_equal "$(wc -l < "$FAKE_STATE_DIR/snoozed-version" | tr -d ' ')" "1" +} + +@test "writes snoozed-until in ISO 8601 UTC format" { + run bash "$SCRIPT" "1.2.3" + assert_success + [ -f "$FAKE_STATE_DIR/snoozed-until" ] + python3 -c " +import re +ts = open('$FAKE_STATE_DIR/snoozed-until').read().strip() +assert re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$', ts), f'bad format: {ts}' +" +} + +@test "snoozed-until is approximately SNOOZE_DAYS in the future" { + bash "$SCRIPT" "1.2.3" + python3 -c " +from datetime import datetime, timezone, timedelta +ts = open('$FAKE_STATE_DIR/snoozed-until').read().strip() +until = datetime.fromisoformat(ts.replace('Z', '+00:00')) +delta = (until - datetime.now(timezone.utc)).total_seconds() +# Allow 60-second tolerance for test execution time +assert abs(delta - 7 * 86400) < 60, f'expected ~7 days, got {delta:.0f}s' +" +} + +@test "respects TRIMKIT_UPDATE_SNOOZE_DAYS override" { + export TRIMKIT_UPDATE_SNOOZE_DAYS=3 + bash "$SCRIPT" "1.2.3" + python3 -c " +from datetime import datetime, timezone +ts = open('$FAKE_STATE_DIR/snoozed-until').read().strip() +until = datetime.fromisoformat(ts.replace('Z', '+00:00')) +delta = (until - datetime.now(timezone.utc)).total_seconds() +assert abs(delta - 3 * 86400) < 60, f'expected ~3 days, got {delta:.0f}s' +" +} + +# --------------------------------------------------------------------------- +# State directory +# --------------------------------------------------------------------------- + +@test "creates state directory if it does not exist" { + run bash "$SCRIPT" "1.2.3" + assert_success + [ -d "$FAKE_STATE_DIR" ] +} + +# --------------------------------------------------------------------------- +# Idempotency +# --------------------------------------------------------------------------- + +@test "second call with same version overwrites snooze" { + bash "$SCRIPT" "1.2.3" + first_until="$(cat "$FAKE_STATE_DIR/snoozed-until")" + + bash "$SCRIPT" "1.2.3" + second_until="$(cat "$FAKE_STATE_DIR/snoozed-until")" + + # Both should be valid timestamps (not checking exact equality due to + # sub-second timing; just confirming the file is rewritten cleanly) + [ -n "$first_until" ] + [ -n "$second_until" ] +} + +@test "second call with different version updates snoozed-version" { + bash "$SCRIPT" "1.0.0" + bash "$SCRIPT" "2.0.0" + assert_equal "$(cat "$FAKE_STATE_DIR/snoozed-version")" "2.0.0" +} From fb04bf1591e4b6a3a2426089003706b2ea3f8656 Mon Sep 17 00:00:00 2001 From: Joseph Fung Date: Sat, 25 Apr 2026 16:51:17 -0400 Subject: [PATCH 2/2] fix: address CodeRabbit review comments - bin/trimkit-update-check: use %q to shell-quote install_dir in notice so copy-paste works for paths with spaces - bin/trimkit-update-check: improve version parser with 4-tuple (major, minor, patch, is_release) so pre-release versions like 1.0.0-rc.1 correctly sort below their corresponding release - bin/trimkit-update-snooze: reject empty version argument - bin/trimkit-update-snooze: clamp SNOOZE_DAYS to min 1 day so a negative/zero value doesn't produce an already-expired snooze - install.sh: route install-dir write failure into warned+=() so it appears in the "=== TrimKit install ===" summary instead of going directly to stderr - package.json: add repository, homepage, license fields - tests: export HOME="$FAKE_HOME" in setup so HOME-based fallback paths resolve into the sandbox - tests: use run bash "$SCRIPT" for the priming call in the "second run" test to keep the harness clean - tests: move mock curl call counter after exit gate so only successful fetches count - tests: pass FAKE_STATE_DIR as sys.argv[1] in inline Python assertions to avoid shell interpolation into Python source --- bin/trimkit-update-check | 28 ++++++++++++---------- bin/trimkit-update-snooze | 7 +++--- install.sh | 7 +++--- package.json | 8 ++++++- tests/bin/trimkit-update-check.bats | 14 +++++++---- tests/bin/trimkit-update-snooze.bats | 36 +++++++++++++++------------- 6 files changed, 60 insertions(+), 40 deletions(-) diff --git a/bin/trimkit-update-check b/bin/trimkit-update-check index b84a071..a3b53bd 100755 --- a/bin/trimkit-update-check +++ b/bin/trimkit-update-check @@ -102,18 +102,21 @@ print(json.load(sys.stdin)['version']) latest_version="$(cat "$STATE_DIR/latest-version" 2>/dev/null)" [ -n "$latest_version" ] || return 0 - # Compare versions using tuple comparison — is latest strictly newer than local? - # int() is guarded with try/except to handle pre-release labels (e.g. 1.0.0-rc.1). + # Compare versions — is latest strictly newer than local? + # Uses a 4-tuple (major, minor, patch, is_release) so pre-release versions + # (e.g. 1.0.0-rc.1) sort below their corresponding release (1.0.0), meaning + # a user on 1.0.0-rc.1 will be notified when 1.0.0 ships. is_newer="$(python3 -c " -import sys +import sys, re def parse(v): - parts = [] - for x in v.strip().split('.')[:3]: - try: - parts.append(int(x)) - except ValueError: - parts.append(0) - return tuple(parts) + m = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?([-+].*)?', v.strip()) + if not m: + return (0, 0, 0, 1) + major = int(m.group(1) or 0) + minor = int(m.group(2) or 0) + patch = int(m.group(3) or 0) + pre = 0 if m.group(4) else 1 # pre-release sorts below release + return (major, minor, patch, pre) print('1' if parse(sys.argv[2]) > parse(sys.argv[1]) else '0') " "$local_version" "$latest_version" 2>/dev/null)" || return 0 [ "$is_newer" = "1" ] || return 0 @@ -132,8 +135,9 @@ sys.exit(0 if datetime.now(timezone.utc) < until else 1) fi fi - # Print the one-line update notice using the actual install path - printf 'trimkit v%s is available (you have v%s). Run: cd %s; git pull; ./install.sh\n' \ + # Print the one-line update notice. %q shell-quotes the install path so the + # generated command is copy-pasteable even when the path contains spaces. + printf 'trimkit v%s is available (you have v%s). Run: cd %q; git pull; ./install.sh\n' \ "$latest_version" "$local_version" "$install_dir" # Auto-snooze via the dedicated script to avoid repeating on every subsequent diff --git a/bin/trimkit-update-snooze b/bin/trimkit-update-snooze index e45a4ae..6a75326 100755 --- a/bin/trimkit-update-snooze +++ b/bin/trimkit-update-snooze @@ -15,8 +15,8 @@ set -euo pipefail STATE_DIR="${TRIMKIT_UPDATE_STATE_DIR:-${HOME}/.trimkit/update-check}" SNOOZE_DAYS="${TRIMKIT_UPDATE_SNOOZE_DAYS:-7}" -if [ $# -lt 1 ]; then - echo "trimkit-update-snooze: error: missing version argument" >&2 +if [ $# -lt 1 ] || [ -z "$1" ]; then + echo "trimkit-update-snooze: error: missing or empty version argument" >&2 echo "Usage: trimkit-update-snooze " >&2 exit 1 fi @@ -31,7 +31,8 @@ mkdir -p "$STATE_DIR" || { snooze_until="$(python3 -c " from datetime import datetime, timezone, timedelta import sys -until = datetime.now(timezone.utc) + timedelta(days=int(sys.argv[1])) +days = max(1, int(sys.argv[1])) # clamp to at least 1 day; negative/zero would expire immediately +until = datetime.now(timezone.utc) + timedelta(days=days) print(until.strftime('%Y-%m-%dT%H:%M:%SZ')) " "$SNOOZE_DAYS")" diff --git a/install.sh b/install.sh index 63cc000..3258299 100755 --- a/install.sh +++ b/install.sh @@ -181,9 +181,10 @@ symlink_files "$SCRIPT_DIR/bin" "$HOME/.trimkit/bin" # Write the absolute path of this trimkit clone to ~/.trimkit/install-dir so # trimkit-update-check can locate the local package.json without resolving # symlinks (which is not portable across macOS and Linux). -printf '%s\n' "$SCRIPT_DIR" > "$HOME/.trimkit/install-dir.tmp" \ - && mv "$HOME/.trimkit/install-dir.tmp" "$HOME/.trimkit/install-dir" \ - || echo "Warning: could not write ~/.trimkit/install-dir — update checks will be skipped" >&2 +if ! { printf '%s\n' "$SCRIPT_DIR" > "$HOME/.trimkit/install-dir.tmp" \ + && mv "$HOME/.trimkit/install-dir.tmp" "$HOME/.trimkit/install-dir"; }; then + warned+=("install-dir (could not write ~/.trimkit/install-dir — update checks will be skipped)") +fi # --------------------------------------------------------------------------- # Plugins diff --git a/package.json b/package.json index b7242bc..d3a0e2e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,11 @@ { "name": "trimkit", "version": "0.5.0", - "description": "Claude Code guardrails and utilities" + "description": "Claude Code guardrails and utilities", + "repository": { + "type": "git", + "url": "https://github.com/josephfung/trimkit.git" + }, + "homepage": "https://github.com/josephfung/trimkit", + "license": "MIT" } diff --git a/tests/bin/trimkit-update-check.bats b/tests/bin/trimkit-update-check.bats index 87ecd38..226ee07 100644 --- a/tests/bin/trimkit-update-check.bats +++ b/tests/bin/trimkit-update-check.bats @@ -25,17 +25,22 @@ setup() { # Call-count file — starts empty; mock curl appends one line per call touch "$MOCK_CURL_CALLS" - # Mock curl — outputs $MOCK_CURL_RESPONSE, records each call, honours $MOCK_CURL_EXIT + # Mock curl — outputs $MOCK_CURL_RESPONSE, records successful calls, honours $MOCK_CURL_EXIT. + # The call counter is incremented only on non-error exits so that tests asserting + # "no curl call" aren't confused by failure-path invocations. cat > "$MOCK_BIN/curl" <<'MOCKCURL' #!/bin/bash -printf '1\n' >> "${MOCK_CURL_CALLS}" if [ "${MOCK_CURL_EXIT:-0}" != "0" ]; then exit "${MOCK_CURL_EXIT}" fi +printf '1\n' >> "${MOCK_CURL_CALLS}" cat "${MOCK_CURL_RESPONSE}" MOCKCURL chmod +x "$MOCK_BIN/curl" + # Override HOME so that any $HOME-based fallback paths in the script resolve + # into the sandbox rather than the real user's ~/.trimkit directory. + export HOME="$FAKE_HOME" export PATH="$MOCK_BIN:$PATH" export TRIMKIT_UPDATE_STATE_DIR="$FAKE_STATE_DIR" export TRIMKIT_UPDATE_INSTALL_DIR="$FAKE_INSTALL_DIR" @@ -218,8 +223,9 @@ print(until.strftime('%Y-%m-%dT%H:%M:%SZ')) @test "second run is silent after auto-snooze is written" { printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" - # First run: notice shown, snooze written - bash "$SCRIPT" + # First run: notice shown, snooze written (output captured to keep harness clean) + run bash "$SCRIPT" + assert_success # Second run: snoozed — must be silent run bash "$SCRIPT" diff --git a/tests/bin/trimkit-update-snooze.bats b/tests/bin/trimkit-update-snooze.bats index 8ea0c5e..3179f8c 100644 --- a/tests/bin/trimkit-update-snooze.bats +++ b/tests/bin/trimkit-update-snooze.bats @@ -30,7 +30,7 @@ teardown() { @test "exits non-zero and prints usage on missing version argument" { run bash "$SCRIPT" assert_failure - assert_output --partial 'missing version argument' + assert_output --partial 'version argument' assert_output --partial 'Usage:' } @@ -54,35 +54,37 @@ teardown() { run bash "$SCRIPT" "1.2.3" assert_success [ -f "$FAKE_STATE_DIR/snoozed-until" ] - python3 -c " -import re -ts = open('$FAKE_STATE_DIR/snoozed-until').read().strip() -assert re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$', ts), f'bad format: {ts}' -" + python3 -c ' +import re, sys +ts = open(sys.argv[1]).read().strip() +assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", ts), f"bad format: {ts}" +' "$FAKE_STATE_DIR/snoozed-until" } @test "snoozed-until is approximately SNOOZE_DAYS in the future" { bash "$SCRIPT" "1.2.3" - python3 -c " -from datetime import datetime, timezone, timedelta -ts = open('$FAKE_STATE_DIR/snoozed-until').read().strip() -until = datetime.fromisoformat(ts.replace('Z', '+00:00')) + python3 -c ' +from datetime import datetime, timezone +import sys +ts = open(sys.argv[1]).read().strip() +until = datetime.fromisoformat(ts.replace("Z", "+00:00")) delta = (until - datetime.now(timezone.utc)).total_seconds() # Allow 60-second tolerance for test execution time -assert abs(delta - 7 * 86400) < 60, f'expected ~7 days, got {delta:.0f}s' -" +assert abs(delta - 7 * 86400) < 60, f"expected ~7 days, got {delta:.0f}s" +' "$FAKE_STATE_DIR/snoozed-until" } @test "respects TRIMKIT_UPDATE_SNOOZE_DAYS override" { export TRIMKIT_UPDATE_SNOOZE_DAYS=3 bash "$SCRIPT" "1.2.3" - python3 -c " + python3 -c ' from datetime import datetime, timezone -ts = open('$FAKE_STATE_DIR/snoozed-until').read().strip() -until = datetime.fromisoformat(ts.replace('Z', '+00:00')) +import sys +ts = open(sys.argv[1]).read().strip() +until = datetime.fromisoformat(ts.replace("Z", "+00:00")) delta = (until - datetime.now(timezone.utc)).total_seconds() -assert abs(delta - 3 * 86400) < 60, f'expected ~3 days, got {delta:.0f}s' -" +assert abs(delta - 3 * 86400) < 60, f"expected ~3 days, got {delta:.0f}s" +' "$FAKE_STATE_DIR/snoozed-until" } # ---------------------------------------------------------------------------