Skip to content

Instantly share code, notes, and snippets.

@vr000m
Last active July 10, 2026 09:28
Show Gist options
  • Select an option

  • Save vr000m/17f39d5fc0cfb10fcb95f8e116370d77 to your computer and use it in GitHub Desktop.

Select an option

Save vr000m/17f39d5fc0cfb10fcb95f8e116370d77 to your computer and use it in GitHub Desktop.
Generate Claude Code spinnerVerbs from recent repo activity (changelogs, releases, merges)
#!/usr/bin/env bash
# Generate Claude Code spinner verbs and merge them into ~/.claude/settings.json
# as spinnerVerbs. Mode defaults to "append" (mixed into the built-in pool);
# set `mode: replace` in the config or SPINNER_MODE=replace to show only these.
#
# Two kinds of output verbs:
# 1. Plain verbs from the config's `verb:` lines (always appended; they also
# replace the built-in rotation pool used to prefix repo entries).
# 2. Decorated entries derived from recent repo activity, one per item:
# <verb> [repo][date]: "title"
#
# Sources come from a config file (default ~/.config/spinner-verbs.conf),
# one per line, # comments allowed:
# verb: Vibing custom pool verb
# /Users/me/Code/myrepo local clone
# ~/Code/other-repo local clone
# pipecat-ai/pipecat GitHub slug (remote)
# https://github.com/owner/repo GitHub URL (remote)
# https://github.com/owner/repo/blob/main/docs/NEWS.md exact changelog file
#
# Per-source fallback chain (each step only if the previous yields nothing
# within the recency window):
# changelog -> releases/tags -> merges to default branch -> latest commit
#
# Remote sources are fetched from GitHub (default branch, common CHANGELOG
# names probed) so stale local clones don't matter. Non-GitHub remotes are
# not supported — clone them locally instead.
#
# No config file means no sources: nothing runs implicitly.
#
# Entries are filtered to the last $SPINNER_MAX_AGE_DAYS (7) days, capped at
# $SPINNER_MAX_PER_REPO (5) per repo.
set -euo pipefail
CONFIG="${SPINNER_CONFIG:-$HOME/.config/spinner-verbs.conf}"
SETTINGS="${SPINNER_SETTINGS:-$HOME/.claude/settings.json}"
MARKER="${SPINNER_MARKER:-$HOME/.claude/.spinner-verbs-last-run}"
LOCKDIR="${SPINNER_LOCKDIR:-$HOME/.claude/.spinner-verbs.lock}"
LOG="${SPINNER_LOG:-$HOME/.claude/spinner-verbs.log}"
MAX_TITLE_LEN=48
MAX_AGE_DAYS="${SPINNER_MAX_AGE_DAYS:-7}"
MAX_PER_REPO="${SPINNER_MAX_PER_REPO:-5}"
MAX_PARALLEL="${SPINNER_MAX_PARALLEL:-3}"
BG_TIMEOUT_SECS="${SPINNER_BG_TIMEOUT_SECS:-300}"
# Regeneration involves network calls per remote source (changelog probe,
# releases, PR search, commits) and can take longer than a SessionStart hook
# budget allows. So: skip entirely if a run finished within TTL_HOURS, and
# otherwise fork the real work into the background and return immediately —
# the hook itself never blocks on network I/O. MARKER is only touched by a
# background worker on confirmed success (see main()), never up front, so a
# failed run doesn't get mistaken for a fresh one.
TTL_HOURS="${SPINNER_TTL_HOURS:-6}"
marker_fresh() {
[[ -f "$MARKER" ]] || return 1
local mtime now age_hours
case "$(uname -s)" in
Darwin) mtime="$(stat -f %m "$MARKER")" ;;
*) mtime="$(stat -c %Y "$MARKER")" ;;
esac
now="$(date +%s)"
age_hours=$(( (now - mtime) / 3600 ))
# A future mtime (clock skew, NTP correction, restored-from-backup marker)
# would otherwise read as fresh forever since age_hours goes negative.
[[ "$age_hours" -ge 0 && "$age_hours" -lt "$TTL_HOURS" ]]
}
cutoff_date() {
case "$(uname -s)" in
Darwin) date -v-"${MAX_AGE_DAYS}"d +%F ;;
*) date -d "${MAX_AGE_DAYS} days ago" +%F ;;
esac
}
# Built-in rotation pool; replaced wholesale by `verb:` config lines.
VERBS=(Shipping Polishing Brewing Refining Tinkering Untangling Rebasing Greasing)
# Global (not `local` to main) so the EXIT trap below can reference them
# safely under `set -u` regardless of which function is on the stack when it
# fires — a trap runs in the caller's scope, where a `local` var of a
# function that already returned would be unbound.
tmp=""
tmpdir=""
# Single trap, set once, for every exit path in the script (normal return,
# early `exit`, or a signal). Setting it more than once would silently
# replace the earlier registration rather than combining them, which is how
# the original tmp-only trap and a later lock-cleanup trap would have ended
# up dropping one or the other's cleanup.
cleanup_on_exit() {
rm -f "$tmp" 2>/dev/null
rm -rf "$tmpdir" 2>/dev/null
[[ "${SPINNER_BG_WORKER:-}" == "1" ]] && rmdir "$LOCKDIR" 2>/dev/null
return 0
}
trap cleanup_on_exit EXIT INT TERM
pick_verb() {
local name="$1" sum
sum="$(printf '%s' "$name" | cksum | cut -d' ' -f1)"
echo "${VERBS[$((sum % ${#VERBS[@]}))]}"
}
gh_api() {
local path="$1" filter="$2"
if command -v gh >/dev/null; then
gh api "$path" --jq "$filter" 2>/dev/null
else
curl -fsSL --max-time 10 "https://api.github.com/$path" 2>/dev/null | jq -r "$filter"
fi
}
# Third-party titles land in the UI verbatim. tr -d '[:cntrl:]' is
# byte-oriented and misses multi-byte Unicode spoofing characters, so filter
# by codepoint with jq instead: C0/C1 controls + DEL, the Arabic letter mark
# and bidi controls (U+061C, U+200E/F, U+202A–202E, U+2066–2069), zero-width
# and joiner characters (U+200B–200D, U+2060, U+FEFF), and tag characters
# (U+E0000–E007F). jq has no hex literals, hence the decimal codepoints.
sanitize_title() {
jq -Rr '[explode[] | select(
. >= 32 and (. < 127 or . > 159)
and . != 1564
and (. < 8203 or . > 8207)
and (. < 8234 or . > 8238)
and . != 8288
and (. < 8294 or . > 8297)
and . != 65279
and (. < 917504 or . > 917631)
)] | implode'
}
# Parse a changelog file: first "##" heading, falling back to the first
# bullet under it when the heading is just "Unreleased" or a version number.
# Prints "date|title" (date may be empty); prints nothing if unparseable.
parse_changelog() {
local file="$1" heading date title bullet
heading="$(grep -m1 -E '^##+ ' "$file" | sed -E 's/^#+ +//')" || true
[[ -z "$heading" ]] && return 0
date="$(grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' <<<"$heading" | head -1)"
title="$(sed -E 's/ ?-? ?\[?[0-9]{4}-[0-9]{2}-[0-9]{2}\]?//; s/^\[|\]$//g' <<<"$heading")"
if [[ "$title" =~ ^(Unreleased|v?[0-9][0-9.]*)$ ]]; then
bullet="$(awk '/^## /{n++} n==1 && /^[-*] /{sub(/^[-*] +/,""); gsub(/\*\*/,""); print; exit}' "$file")"
[[ -n "$bullet" ]] && title="$bullet"
fi
printf '%s|%s\n' "$date" "$title"
}
# Merge-commit subjects are boilerplate ("Merge pull request #N from x/y");
# the PR title usually lives in the body's first line.
merge_title() {
local subject="$1" body="$2"
if [[ "$subject" == "Merge pull request #"* || "$subject" == "Merge branch "* ]] && [[ -n "$body" ]]; then
printf '%s\n' "$body"
else
printf '%s\n' "$subject"
fi
}
# --- Local clone -------------------------------------------------------------
# Each entry_* helper prints zero or more "name|date|title" lines.
local_default_branch() {
local repo="$1" ref
ref="$(git -C "$repo" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" || true
if [[ -n "$ref" ]]; then
echo "${ref#origin/}"
elif git -C "$repo" show-ref --verify --quiet refs/heads/main; then
echo main
else
echo master
fi
}
entry_local() {
local repo="$1" cutoff="$2" name changelog entry date title
name="$(basename "$repo")"
# 1. Changelog
changelog="$(find "$repo" -maxdepth 1 -iname 'CHANGELOG*' -type f | head -1)"
if [[ -n "$changelog" ]]; then
entry="$(parse_changelog "$changelog")"
if [[ -n "$entry" ]]; then
date="${entry%%|*}"; title="${entry#*|}"
[[ -z "$date" ]] && date="$(git -C "$repo" log -1 --format=%as -- "$changelog" 2>/dev/null || true)"
if [[ -n "$date" && ! "$date" < "$cutoff" ]]; then
printf '%s|%s|%s\n' "$name" "$date" "$title"
return 0
fi
fi
fi
# 2. Releases (tags)
entry="$(git -C "$repo" tag --sort=-creatordate --format='%(creatordate:short)|%(refname:short)' 2>/dev/null | head -1)" || true
if [[ -n "$entry" ]]; then
date="${entry%%|*}"; title="released ${entry#*|}"
if [[ -n "$date" && ! "$date" < "$cutoff" ]]; then
printf '%s|%s|%s\n' "$name" "$date" "$title"
return 0
fi
fi
# 3. Merges to default branch within the window
local branch merged
branch="$(local_default_branch "$repo")"
# head counts lines, not merges: multi-line %b bodies eat into the budget,
# so this can yield fewer than MAX_PER_REPO entries. Under-full is fine.
merged="$(git -C "$repo" log --merges --first-parent "$branch" --since="$cutoff" \
--format='%as%x09%s%x09%b' 2>/dev/null | head -"$MAX_PER_REPO")" || true
if [[ -n "$merged" ]]; then
local subject body
while IFS=$'\t' read -r date subject body; do
# %b is multi-line; continuation lines lack the leading ISO date — skip.
[[ "$date" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || continue
title="$(merge_title "$subject" "$body")"
printf '%s|%s|%s\n' "$name" "$date" "$title"
done <<<"$merged"
return 0
fi
# 4. Latest commit
entry="$(git -C "$repo" log -1 --format='%as|%s' 2>/dev/null)" || return 0
date="${entry%%|*}"
[[ -n "$date" && ! "$date" < "$cutoff" ]] && printf '%s|%s\n' "$name" "$entry"
return 0
}
# --- GitHub remote -----------------------------------------------------------
entry_remote() {
local slug="$1" file="$2" cutoff="$3" ref="${4:-}" name branch tmp candidate entry date title
name="${slug##*/}"
branch="$(gh_api "repos/$slug" '.default_branch')" || true
[[ -z "$branch" ]] && return 0
# 1. Changelog ($ref overrides the default branch for explicit file URLs)
tmp="$(mktemp)"
local found=""
if [[ -n "$file" ]]; then
curl -fsSL --max-time 10 "https://raw.githubusercontent.com/$slug/${ref:-$branch}/$file" -o "$tmp" 2>/dev/null && found="$file"
else
for candidate in CHANGELOG.md CHANGELOG Changelog.md changelog.md CHANGES.md NEWS.md; do
if curl -fsSL --max-time 10 "https://raw.githubusercontent.com/$slug/$branch/$candidate" -o "$tmp" 2>/dev/null; then
found="$candidate"; break
fi
done
fi
local result=""
if [[ -n "$found" ]]; then
entry="$(parse_changelog "$tmp")"
if [[ -n "$entry" ]]; then
date="${entry%%|*}"; title="${entry#*|}"
[[ -z "$date" ]] && date="$(gh_api "repos/$slug/commits?path=$found&per_page=1${ref:+&sha=$ref}" '.[0].commit.committer.date' | cut -dT -f1)"
[[ -n "$date" && ! "$date" < "$cutoff" ]] && result="$name|$date|$title"
fi
fi
rm -f "$tmp"
if [[ -n "$result" ]]; then
printf '%s\n' "$result"
return 0
fi
# 2. Releases
entry="$(gh_api "repos/$slug/releases?per_page=1" \
'.[0] | select(.published_at != null) | "\(.published_at)|\(.name // .tag_name)"')" || true
if [[ -n "$entry" ]]; then
date="$(cut -dT -f1 <<<"${entry%%|*}")"; title="released ${entry#*|}"
if [[ -n "$date" && ! "$date" < "$cutoff" ]]; then
printf '%s|%s|%s\n' "$name" "$date" "$title"
return 0
fi
fi
# 3. PRs merged to the default branch within the window
local merged
merged="$(gh_api "search/issues?q=repo:$slug+is:pr+is:merged+merged:%3E%3D$cutoff&per_page=$MAX_PER_REPO" \
'.items[] | "\(.closed_at)|\(.title)"')" || true
if [[ -n "$merged" ]]; then
while IFS='|' read -r date title; do
[[ -z "$date" ]] && continue
printf '%s|%s|%s\n' "$name" "$(cut -dT -f1 <<<"$date")" "$title"
done <<<"$merged"
return 0
fi
# 4. Latest commit
entry="$(gh_api "repos/$slug/commits?per_page=1" '"\(.[0].commit.committer.date)|\(.[0].commit.message)"' | head -1)" || true
[[ -z "$entry" || "$entry" == "null|null" ]] && return 0
date="$(cut -dT -f1 <<<"${entry%%|*}")"
[[ -n "$date" && ! "$date" < "$cutoff" ]] && printf '%s|%s|%s\n' "$name" "$date" "${entry#*|}"
return 0
}
# Route one config source line to entry_local or entry_remote.
entries_for_source() {
local src="$1" cutoff="$2" slug rest
case "$src" in
/*|"~"*)
entry_local "${src/#\~/$HOME}" "$cutoff" ;;
*github.com*|*githubusercontent.com*)
rest="${src#*github.com/}"
rest="${rest#*githubusercontent.com/}"
rest="${rest%.git}"
slug="$(cut -d/ -f1,2 <<<"$rest")"
# blob/raw URLs carry branch + file path after the slug
local path ref="" file=""
path="${rest#"$slug"}"; path="${path#/}"
if [[ -n "$path" ]]; then
path="${path#blob/}"
ref="${path%%/*}" # honor the URL's branch segment
file="${path#*/}"
fi
entry_remote "$slug" "$file" "$cutoff" "$ref" ;;
*/*)
entry_remote "$src" "" "$cutoff" ;;
*)
echo "skipping unrecognized source: $src" >&2 ;;
esac
}
main() {
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
[[ -f "$SETTINGS" ]] || { echo "settings file not found: $SETTINGS" >&2; exit 1; }
# Once, not per-call: gh_api runs in command substitutions, so a flag set
# there wouldn't persist. Anonymous GitHub API is limited to 60 req/hour;
# many remote sources can silently come back empty when it's exhausted.
command -v gh >/dev/null || \
echo "gh not found; remote sources use the anonymous GitHub API (60 req/hour)" >&2
local cutoff verbs_json="[]" pool_verbs=() sources=() mode_cfg=""
cutoff="$(cutoff_date)"
# Parse config: `verb:` lines build the custom pool, `mode:` sets the
# spinnerVerbs mode (append|replace); the rest are sources. No config file
# means no sources — nothing to scan implicitly.
if [[ -f "$CONFIG" ]]; then
local line
while IFS= read -r line; do
line="${line%%#*}"
line="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' <<<"$line")"
[[ -z "$line" ]] && continue
if [[ "$line" == verb:* ]]; then
pool_verbs+=("$(sed -E 's/^verb:[[:space:]]*//' <<<"$line")")
elif [[ "$line" == mode:* ]]; then
mode_cfg="$(sed -E 's/^mode:[[:space:]]*//' <<<"$line")"
else
sources+=("$line")
fi
done < "$CONFIG"
fi
# Custom pool verbs: appended standalone AND used as the rotation pool.
if [[ ${#pool_verbs[@]} -gt 0 ]]; then
VERBS=("${pool_verbs[@]}")
local v
for v in "${pool_verbs[@]}"; do
verbs_json="$(jq --arg v "$v" '. + [$v]' <<<"$verbs_json")"
done
fi
# Each source's lookup chain is independent network/git I/O (changelog
# probe, releases, PR search, commits), so fan them out concurrently
# instead of paying their latency serially. Order across sources doesn't
# matter for the final pool, only within a source's own entries. Capped at
# MAX_PARALLEL in-flight sources at a time so a long source list doesn't
# fire a burst of simultaneous GitHub API calls (anonymous access is
# limited to 60 req/hour, and GitHub also applies secondary concurrent-
# request limits even when authenticated).
local src entry name date title verb count_for_repo i src_files=()
tmpdir="$(mktemp -d)"
i=0
for src in ${sources[@]+"${sources[@]}"}; do
local sf="$tmpdir/$i"
src_files+=("$sf")
( entries_for_source "$src" "$cutoff" > "$sf" 2>>"$tmpdir/stderr" ) &
i=$((i + 1))
if (( i % MAX_PARALLEL == 0 )); then
wait
fi
done
wait
[[ -f "$tmpdir/stderr" ]] && cat "$tmpdir/stderr" >&2
for sf in ${src_files[@]+"${src_files[@]}"}; do
count_for_repo=0
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
IFS='|' read -r name date title <<<"$entry"
# ISO dates compare lexicographically; skip stale or undated entries.
[[ -z "$date" || "$date" < "$cutoff" ]] && continue
# Strip control + Unicode spoofing chars (see sanitize_title).
title="$(sanitize_title <<<"$title")"
if [[ $count_for_repo -ge $MAX_PER_REPO ]]; then
echo "capping $name at $MAX_PER_REPO entries" >&2
break
fi
[[ ${#title} -gt $MAX_TITLE_LEN ]] && title="${title:0:$MAX_TITLE_LEN}…"
verb="$(pick_verb "$name") [$name][$date]: \"$title\""
verbs_json="$(jq --arg v "$verb" '. + [$v]' <<<"$verbs_json")"
count_for_repo=$((count_for_repo + 1))
done < "$sf"
done
local count
count="$(jq 'length' <<<"$verbs_json")"
if [[ "$count" -eq 0 ]]; then
echo "no custom verbs and no activity in the last $MAX_AGE_DAYS days; leaving $SETTINGS unchanged" >&2
touch "$MARKER"
exit 0
fi
# "append" mixes into Claude Code's large built-in pool (custom verbs show
# up rarely); "replace" shows only these. Env overrides config.
local mode
mode="${SPINNER_MODE:-${mode_cfg:-append}}"
if [[ "$mode" != "append" && "$mode" != "replace" ]]; then
echo "invalid mode '$mode' (expected append or replace); using append" >&2
mode="append"
fi
# Same-directory temp file so the mv is a true atomic rename (no cross-device
# copy out of tmpfs), and mktemp's 0600 mode doesn't stick to settings.json.
# cleanup_on_exit (trapped once, near the top of the script) removes it on
# any exit path, including a TERM from an external watchdog/timeout.
local perms
tmp="$(mktemp "${SETTINGS}.XXXXXX")"
case "$(uname -s)" in
Darwin) perms="$(stat -f %Lp "$SETTINGS")" ;;
*) perms="$(stat -c %a "$SETTINGS")" ;;
esac
chmod "$perms" "$tmp"
jq --argjson verbs "$verbs_json" --arg mode "$mode" \
'.spinnerVerbs = {mode: $mode, verbs: $verbs}' \
"$SETTINGS" > "$tmp" || exit 1 # trap cleans up the temp file
mv "$tmp" "$SETTINGS"
# Only mark the run fresh once settings.json has actually been rewritten,
# so a failure earlier in main() (missing jq/gh, bad config, mv failure)
# leaves the marker stale and the next SessionStart hook retries instead
# of silently skipping for the rest of the TTL window.
touch "$MARKER"
echo "wrote $count spinner verbs to $SETTINGS (mode: $mode)"
}
# SessionStart hooks have a short timeout budget, but regeneration involves
# per-source network I/O that can legitimately run long (slow GitHub API,
# many sources). Rather than risk the hook getting killed mid-write, the
# hook invocation only ever does a cheap marker check: if a run completed
# within TTL_HOURS, skip; otherwise it acquires LOCKDIR (an atomic `mkdir`,
# so two hook invocations racing at startup can't both fork a worker) and
# forks the real work into the background, detached from the hook's process
# group, returning immediately. The forked worker re-execs with
# SPINNER_BG_WORKER=1 so it runs main() for real under a watchdog timeout
# (backgrounding removed the SessionStart hook's own timeout, which used to
# bound a hung `gh`/network call) and only touches MARKER — inside main(),
# see above — once settings.json has actually been rewritten.
if [[ "${SPINNER_BG_WORKER:-}" == "1" ]]; then
main "$@" &
worker_pid=$!
( sleep "$BG_TIMEOUT_SECS"; kill -TERM "$worker_pid" 2>/dev/null ) &
watchdog_pid=$!
wait "$worker_pid"
status=$?
kill -TERM "$watchdog_pid" 2>/dev/null
exit "$status"
elif marker_fresh; then
echo "spinner verbs refreshed within the last ${TTL_HOURS}h; skipping" >&2
elif mkdir -p "$(dirname "$MARKER")" && mkdir "$LOCKDIR" 2>/dev/null; then
nohup env SPINNER_BG_WORKER=1 "$0" "$@" >>"$LOG" 2>&1 &
disown
echo "spinner verb regeneration started in background (log: $LOG)"
else
echo "spinner verb regeneration already in progress; skipping" >&2
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment