Skip to content

Instantly share code, notes, and snippets.

@andrewvaughan
Last active March 20, 2026 21:36
Show Gist options
  • Select an option

  • Save andrewvaughan/40f267e3161c5a7c10f3efec4d02bcdf to your computer and use it in GitHub Desktop.

Select an option

Save andrewvaughan/40f267e3161c5a7c10f3efec4d02bcdf to your computer and use it in GitHub Desktop.
Claude Code macOS notifications via hooks (permission prompts, questions, and completion)
#!/bin/bash
set -euo pipefail
INPUT="$(cat)"
# Skip if this is a subagent stopping, not the main agent
AGENT_ID="$(echo "$INPUT" | jq -r '.agent_id // empty')"
if [[ -n "$AGENT_ID" ]]; then
exit 0
fi
CWD="$(echo "$INPUT" | jq -r '.cwd // ""')"
PROJECT="$(basename "$CWD")"
terminal-notifier \
-title "Claude Code — $PROJECT" \
-message "Done" \
-sound Glass >/dev/null 2>&1
#!/bin/bash
set -eo pipefail
INPUT="$(cat)"
CWD="$(echo "$INPUT" | jq -r '.cwd // ""')"
PROJECT="$(basename "$CWD")"
terminal-notifier \
-title "Claude Code — $PROJECT" \
-message "Claude is asking a question" \
-sound Glass >/dev/null 2>&1
#!/bin/bash
set -eo pipefail
INPUT="$(cat)"
CWD="$(echo "$INPUT" | jq -r '.cwd // ""')"
PROJECT="$(basename "$CWD")"
MESSAGE="$(echo "$INPUT" | jq -r '.message // "Waiting for permission"')"
terminal-notifier \
-title "Claude Code — $PROJECT" \
-message "$MESSAGE" \
-sound Glass >/dev/null 2>&1
{
"permissions": {
"allow": []
},
"hooks": {
"PreToolUse": [
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/notify-needs-attention.sh"
}
]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/notify-permission.sh"
}
]
}
],
"PermissionRequest": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/notify-permission.sh"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/notify-done.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# =============================================================================
# Claude Code Status Line
# =============================================================================
#
# A custom status line script for Claude Code that displays project context,
# GitHub integration, usage metrics, and account info in a color-coded bar.
#
# DISPLAY ORDER:
# Repo │ Build # │ PR # (issues) │ Issue # │ Ctx │ Session │ 5h │ 7d │ Org
#
# SETUP:
# 1. Save this file somewhere (e.g., ~/.claude/statusline-command.sh)
# 2. Make it executable: chmod +x ~/.claude/statusline-command.sh
# 3. Add to ~/.claude/settings.json:
# {
# "statusLine": {
# "type": "command",
# "command": "~/.claude/statusline-command.sh"
# }
# }
#
# DEPENDENCIES:
# - jq (required) — parses the JSON that Claude Code pipes to stdin
# - git (optional) — repo name, branch detection, GitHub URL construction
# - gh (optional) — PR lookup and linked issue detection (GitHub CLI)
# - claude (optional) — org/subscription info via `claude auth status`
#
# HOW EACH SECTION WORKS:
#
# Repo Git remote origin name. Requires: git remote configured.
#
# Build # Extracted from the working directory path if it contains
# "build-##" (e.g., /path/build-08/repo → "Build #08").
# Useful when running multiple parallel clones.
#
# PR # Looks up an open PR for the current branch via `gh pr view`.
# Parses the PR body for "Closes/Fixes/Resolves #N" references
# and shows them as clickable links. Cached for 10 minutes.
# Skill workflows clear the cache on PR/branch state changes.
# Requires: gh CLI, authenticated with GitHub.
#
# Issue # Parsed from the branch name using the convention:
# <type>/<issue#>-<slug> (e.g., feature/123-user-auth → Issue #123).
# Valid types: feature, fix, docs, refactor, test, chore, security,
# hotfix, bug, task, issue, feat. Skipped if already shown in PR.
# Requires: branch follows the naming convention.
#
# Ctx Context window usage percentage + window size (e.g., "63% 200k").
# Color-coded: green <40%, yellow 40-59%, orange 60-79%, red 80%+.
# Source: Claude Code JSON (always available).
#
# Session Cumulative API cost for the current session (e.g., "$1.09").
# Source: Claude Code JSON (always available).
#
# 5h / 7d Rate limit usage for the 5-hour and 7-day windows, with time
# until reset. Color-coded like Ctx. Only shown for Pro/Max plans.
# Source: Claude Code JSON (available after first API response).
#
# Org Anthropic organization name and subscription tier from
# `claude auth status`. Strips trailing "Organization" from the
# name. Cached for 5 minutes. Requires: claude CLI, logged in.
#
# CACHING:
# - PR lookup: /tmp/claude-statusline-pr-<hash> (10min TTL, cleared by skill workflows)
# - Org info: /tmp/claude-statusline-org (5min TTL)
# To force refresh, delete the cache files.
#
# CLICKABLE LINKS:
# PR numbers and issue numbers are rendered as OSC 8 hyperlinks pointing to
# their GitHub URLs. Requires a terminal that supports OSC 8 (most modern
# terminals: iTerm2, Wezterm, Windows Terminal, GNOME Terminal 3.26+, etc.).
#
# COLOR SCHEME:
# Repo name .... bold cyan Rate limit labels .. dim
# Build # ...... bright blue Percentages ........ green/yellow/orange/red
# PR # ......... magenta Reset times ........ dim
# Issue # ...... yellow Org name ........... cyan
# Labels ....... dim Subscription ....... dim
# Separator .... dim │
#
# =============================================================================
input=$(cat)
# ANSI colors
DIM='\033[2m'
BOLD='\033[1m'
RESET='\033[0m'
CYAN='\033[36m'
BLUE='\033[94m'
ORANGE='\033[38;5;208m'
MAGENTA='\033[35m'
GREEN='\033[32m'
YELLOW='\033[33m'
RED='\033[31m'
SEP="${DIM} │ ${RESET}"
# Color a percentage: green <40, yellow 40-59, orange 60-79, red 80+
pct_color() {
local val=$1
if [ "$val" -ge 80 ]; then echo -n "${RED}"
elif [ "$val" -ge 60 ]; then echo -n "${ORANGE}"
elif [ "$val" -ge 40 ]; then echo -n "${YELLOW}"
else echo -n "${GREEN}"
fi
}
parts=()
cwd=$(echo "$input" | jq -r '.cwd // empty')
# --- Repo name and base URL for links ---
gh_base=""
if [ -n "$cwd" ] && command -v git >/dev/null 2>&1; then
repo_url=$(git -C "$cwd" --no-optional-locks remote get-url origin 2>/dev/null)
if [ -n "$repo_url" ]; then
repo_name=$(basename -s .git "$repo_url")
# Build GitHub base URL from remote (handles ssh and https)
gh_base=$(echo "$repo_url" | sed -E 's|git@github\.com:|https://github.com/|; s|\.git$||')
parts+=("${BOLD}${CYAN}${repo_name}${RESET}")
fi
fi
# OSC 8 hyperlink helper: link URL TEXT
link() {
printf '\033]8;;%s\a%s\033]8;;\a' "$1" "$2"
}
# --- Build number from cwd ---
build=$(echo "$cwd" | grep -oE 'build-[0-9]+' | head -1)
if [ -n "$build" ]; then
build_num=$(echo "$build" | grep -oE '[0-9]+')
parts+=("${BLUE}Build #${build_num}${RESET}")
fi
# --- Get branch once for PR and Issue sections ---
branch=""
if [ -n "$cwd" ] && command -v git >/dev/null 2>&1; then
branch=$(git -C "$cwd" --no-optional-locks branch --show-current 2>/dev/null)
fi
# --- PR number and associated issue numbers (via gh CLI, cached) ---
pr_issues_from_pr=""
if [ -n "$branch" ] && [ "$branch" != "main" ] && [ "$branch" != "master" ] && command -v gh >/dev/null 2>&1; then
cache_key=$(echo "$cwd:$branch" | md5 -q 2>/dev/null || echo "$cwd:$branch" | md5sum 2>/dev/null | cut -d' ' -f1)
cache_file="/tmp/claude-statusline-pr-${cache_key}"
if [ ! -f "$cache_file" ] || [ $(( $(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || stat -c%Y "$cache_file" 2>/dev/null || echo 0) )) -gt 600 ]; then
(cd "$cwd" && gh pr view "$branch" --json number,body 2>/dev/null) > "$cache_file" || echo "" > "$cache_file"
fi
pr_json=$(cat "$cache_file")
if [ -n "$pr_json" ]; then
pr_num=$(echo "$pr_json" | jq -r '.number // empty' 2>/dev/null)
if [ -n "$pr_num" ]; then
pr_body=$(echo "$pr_json" | jq -r '.body // empty' 2>/dev/null)
issue_refs=$(echo "$pr_body" | grep -oiE '(closes|fixes|resolves|references|refs|re)[[:space:]:#]+[0-9]+' | grep -oE '[0-9]+' | sort -un | tr '\n' ',' | sed 's/,$//')
pr_link=$(link "${gh_base}/pull/${pr_num}" "${MAGENTA}PR #${pr_num}${RESET}")
if [ -n "$issue_refs" ]; then
issue_links=""
first=1
for inum in $(echo "$issue_refs" | tr ',' ' '); do
if [ "$first" -eq 1 ]; then first=0; else issue_links="${issue_links}, "; fi
issue_links="${issue_links}$(link "${gh_base}/issues/${inum}" "#${inum}")"
done
parts+=("${pr_link} ${DIM}(${issue_links})${RESET}")
pr_issues_from_pr="$issue_refs"
else
parts+=("${pr_link}")
fi
fi
fi
fi
# --- GitHub issue number (from branch name) ---
# Convention: <type>/<issue#>-<slug> (e.g., feature/123-user-auth, fix/456-bug)
if [ -n "$branch" ]; then
if echo "$branch" | grep -qE '^(feature|fix|docs|refactor|test|chore|security|hotfix|bug|task|issue|feat)/'; then
issue_raw=$(echo "$branch" | sed -E 's|^[^/]+/||' | grep -oE '^[0-9]+' | head -1)
issue=$(echo "$issue_raw" | sed 's/^0*//')
if [ -n "$issue" ]; then
if [ -z "$pr_issues_from_pr" ] || ! echo ",$pr_issues_from_pr," | grep -q ",$issue,"; then
parts+=("$(link "${gh_base}/issues/${issue}" "${YELLOW}Issue #${issue}${RESET}")")
fi
fi
fi
fi
# --- Context window usage ---
used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty')
ctx_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty')
if [ -n "$used_pct" ]; then
used_int=$(printf '%.0f' "$used_pct")
color=$(pct_color "$used_int")
size_str=""
if [ -n "$ctx_size" ]; then
size_k=$(( ctx_size / 1000 ))
size_str=" ${DIM}${size_k}k${RESET}"
fi
parts+=("${DIM}Ctx${RESET} ${color}${used_int}%${RESET}${size_str}")
fi
# --- Session cost ---
cost=$(echo "$input" | jq -r '.cost.total_cost_usd // empty')
if [ -n "$cost" ]; then
cost_fmt=$(printf '$%.2f' "$cost")
parts+=("${DIM}Session${RESET} ${cost_fmt}")
fi
# --- 5-hour rate limit ---
five_pct=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
five_reset=$(echo "$input" | jq -r '.rate_limits.five_hour.resets_at // empty')
if [ -n "$five_pct" ]; then
five_int=$(printf '%.0f' "$five_pct")
color=$(pct_color "$five_int")
reset_str=""
if [ -n "$five_reset" ]; then
now=$(date +%s)
diff=$(( five_reset - now ))
if [ "$diff" -le 0 ]; then
reset_str=" ${DIM}now${RESET}"
elif [ "$diff" -lt 3600 ]; then
mins=$(( diff / 60 ))
reset_str=" ${DIM}${mins}m${RESET}"
else
hrs=$(( diff / 3600 ))
mins=$(( (diff % 3600) / 60 ))
if [ "$mins" -gt 0 ]; then
reset_str=" ${DIM}${hrs}h${mins}m${RESET}"
else
reset_str=" ${DIM}${hrs}h${RESET}"
fi
fi
fi
parts+=("${DIM}5h${RESET} ${color}${five_int}%${RESET}${reset_str}")
fi
# --- 7-day rate limit ---
seven_pct=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty')
seven_reset=$(echo "$input" | jq -r '.rate_limits.seven_day.resets_at // empty')
if [ -n "$seven_pct" ]; then
seven_int=$(printf '%.0f' "$seven_pct")
color=$(pct_color "$seven_int")
reset_str=""
if [ -n "$seven_reset" ]; then
now=$(date +%s)
diff=$(( seven_reset - now ))
if [ "$diff" -le 0 ]; then
reset_str=" ${DIM}now${RESET}"
elif [ "$diff" -lt 3600 ]; then
mins=$(( diff / 60 ))
reset_str=" ${DIM}${mins}m${RESET}"
elif [ "$diff" -lt 86400 ]; then
hrs=$(( diff / 3600 ))
mins=$(( (diff % 3600) / 60 ))
if [ "$mins" -gt 0 ]; then
reset_str=" ${DIM}${hrs}h${mins}m${RESET}"
else
reset_str=" ${DIM}${hrs}h${RESET}"
fi
else
days=$(( diff / 86400 ))
hrs=$(( (diff % 86400) / 3600 ))
if [ "$hrs" -gt 0 ]; then
reset_str=" ${DIM}${days}d${hrs}h${RESET}"
else
reset_str=" ${DIM}${days}d${RESET}"
fi
fi
fi
parts+=("${DIM}7d${RESET} ${color}${seven_int}%${RESET}${reset_str}")
fi
# --- Org name + subscription (cached 5 min, refreshes after /login) ---
org_cache="/tmp/claude-statusline-org"
if [ ! -f "$org_cache" ] || [ $(( $(date +%s) - $(stat -f%m "$org_cache" 2>/dev/null || stat -c%Y "$org_cache" 2>/dev/null || echo 0) )) -gt 300 ]; then
if command -v claude >/dev/null 2>&1; then
claude auth status 2>/dev/null | jq -r '"\(.orgName // "")\t\(.subscriptionType // "")"' > "$org_cache" 2>/dev/null
fi
fi
org_line=$(cat "$org_cache" 2>/dev/null)
if [ -n "$org_line" ]; then
org_name=$(echo "$org_line" | cut -f1)
sub_type=$(echo "$org_line" | cut -f2)
# Strip trailing "Organization" (with optional leading space/'s)
org_name=$(echo "$org_name" | sed -E "s/['\u2019]?s?[[:space:]]*Organization[[:space:]]*$//")
org_display="${CYAN}${org_name}${RESET}"
if [ -n "$sub_type" ]; then
org_display="${org_display} ${DIM}(${sub_type})${RESET}"
fi
parts+=("${DIM}Org${RESET} ${org_display}")
fi
# --- Output all parts joined by separator ---
if [ "${#parts[@]}" -gt 0 ]; then
output=""
for i in "${!parts[@]}"; do
if [ "$i" -eq 0 ]; then
output="${parts[$i]}"
else
output="${output}${SEP}${parts[$i]}"
fi
done
echo -e "$output"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment