From 65d698b3d1906f2c15143fa09420b4be9ec77705 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Wed, 8 Jul 2026 10:06:10 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B0=20chore:=20=F0=9F=94=A5=20statusli?= =?UTF-8?q?ne=20+=20gh-inbox=20moved=20to=20icanalytica/ica-skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical home is now the ica-skills claude-statusline skill (icanalytica/ica-skills#41): install once with /claude-statusline and the plugin's session-start sync hook keeps the installed copies in ~/.claude current from then on — no more manual porting between repos. CLAUDE.md points fresh machines at the one-time install; settings.json keeps its statusLine command, harmlessly dead until then. Co-Authored-By: Claude Fable 5 --- .claude/gh-inbox | 184 ------------------ .claude/statusline.sh | 436 ------------------------------------------ CLAUDE.md | 1 + 3 files changed, 1 insertion(+), 620 deletions(-) delete mode 100755 .claude/gh-inbox delete mode 100755 .claude/statusline.sh diff --git a/.claude/gh-inbox b/.claude/gh-inbox deleted file mode 100755 index af81934fe09..00000000000 --- a/.claude/gh-inbox +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env bash - -# gh-inbox — cross-account GitHub inbox checker. -# -# Sweeps every account logged into the gh CLI and distills "what needs my -# attention" into one JSON file that consumers (the Claude Code statusline, -# a future TUI, anything with jq) read instead of talking to GitHub: -# -# ${XDG_CACHE_HOME:-~/.cache}/gh-inbox/inbox.json -# { "updated": "…Z", "repos": { "org/repo": { "todo": N, "fyi": N, -# "items": [ { "class", "type", "title", "reason", "url", "account" } ] } } } -# -# Everything counted is a live PR, issue, or discussion — never a -# notification event. GitHub's notifications feed is deliberately not used: -# its unread flag clears on a glance (or an email), which says nothing -# about whether the item still needs me. Two classes, both cleared only by -# the state of the item itself, no local read/dismiss state: -# -# todo — PRs awaiting my review, issues/PRs assigned to me, repo -# invitations. Ground truth of the obligation: an item drops out -# exactly when it is resolved on GitHub (review submitted, -# closed/unassigned, invite accepted). Not opt-out-able. -# fyi — open PRs, issues, and discussions that @mention me. Clears when -# the item is closed/merged, or when I opt out by unsubscribing -# from the thread on GitHub — the GraphQL viewerSubscription field -# is checked, so an Unsubscribe click is respected here too. -# -# Design rules: -# - Silent always. This usually runs detached from a statusline; nothing -# watches stdout and a transient failure must not spam anything. Every -# probe is guarded, errors are discarded, exit is always 0. -# - No cadence logic. Running it refreshes, full stop; callers own the -# schedule (the statusline throttles itself with a stamp file), and a -# manual run is a forced refresh. -# - Trust no token: `gh auth token --user X` can hand back a token that -# belongs to a DIFFERENT account (observed live — a stale keyring slot -# returned another user's token). Every account's token is verified -# against `api user` first; a mismatch fails the account, because -# queries as the wrong identity return silently incomplete results, -# not errors. -# - All-or-nothing per account: an account contributes fresh items only -# when its identity check and all four queries succeed. A failed -# account's items are carried forward from the previous cache instead -# — last verified truth beats both a false zero and a frozen file. -# - The cache is rewritten only when at least one account fully -# answered; offline keeps the previous contents untouched, and readers -# gate on the file's mtime to catch a dead checker. - -command -v jq >/dev/null 2>&1 || exit 0 -command -v gh >/dev/null 2>&1 || exit 0 - -hosts="$HOME/.config/gh/hosts.yml" -[[ -r $hosts ]] || exit 0 - -# Every logged-in account: the keys of the `users:` map in hosts.yml. The -# awk is indent-sensitive (users: at 4 spaces, account keys at 8) so the -# host block's own `user:` key — the single ACTIVE account — can't match. -# Tokens are pinned per API call with GH_TOKEN, never `gh auth switch`: -# the active account is someone else's state and this script runs in the -# background. -accounts=$(awk ' - /^github\.com:/ { in_host = 1; next } - in_host && /^[^[:space:]]/ { in_host = 0; in_users = 0 } - in_host && $1 == "users:" { in_users = 1; next } - in_users && /^ [^[:space:]]+:$/ { u = $1; sub(/:$/, "", u); print u; next } - in_users && /^ [^[:space:]]/ { in_users = 0 } -' "$hosts" 2>/dev/null) -[[ -n $accounts ]] || exit 0 - -cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/gh-inbox" -mkdir -p "$cache_dir" 2>/dev/null || exit 0 -inbox_file="$cache_dir/inbox.json" - -# fetch — GET the endpoint and -# normalize the response to a JSON array of {repo, class, type, title, -# reason, url, account}. Fails if either step does, so the caller can -# fail the whole account (all-or-nothing rule above). -fetch() { - local resp - resp=$(GH_TOKEN=$1 gh api "$2" 2>/dev/null) || return 1 - jq -c --arg account "$3" "$4" <<<"$resp" 2>/dev/null -} - -# REST normalizers. Search results carry only an API repository_url, so -# the org/repo key is its tail. The review-requested query is type:pr by -# construction; the assignee query mixes issues and PRs, told apart by -# the .pull_request stub. -review_jq='[.items[] | {repo: (.repository_url | sub("^.*/repos/"; "")), - class: "todo", type: "pr", title: .title, - reason: "review_requested", url: .html_url, account: $account}]' -assign_jq='[.items[] | {repo: (.repository_url | sub("^.*/repos/"; "")), - class: "todo", type: (if .pull_request then "pr" else "issue" end), - title: .title, reason: "assigned", url: .html_url, account: $account}]' -invite_jq='[.[] | {repo: .repository.full_name, class: "todo", - type: "invite", title: ("invitation to " + .repository.full_name), - reason: "invitation", url: .html_url, account: $account}]' - -# Mentions go through GraphQL, not REST search, for one reason: -# viewerSubscription. It reports the queried account's own subscription -# to each thread, so an Unsubscribe/Ignore on GitHub drops the item — -# that's the opt-out. type: ISSUE covers issues AND PRs (told apart by -# __typename); discussions are a separate search type and carry a -# closed flag instead of a state qualifier. -mention_gql='query($qi: String!, $qd: String!) { - issues: search(query: $qi, type: ISSUE, first: 100) { nodes { - __typename - ... on Issue { title url repository { nameWithOwner } viewerSubscription } - ... on PullRequest { title url repository { nameWithOwner } viewerSubscription } } } - discussions: search(query: $qd, type: DISCUSSION, first: 100) { nodes { - ... on Discussion { title url closed repository { nameWithOwner } viewerSubscription } } } -}' -mention_jq='[ - (.data.issues.nodes[] | select(.url) - | select(.viewerSubscription != "UNSUBSCRIBED" and .viewerSubscription != "IGNORED") - | {repo: .repository.nameWithOwner, class: "fyi", - type: (if .__typename == "PullRequest" then "pr" else "issue" end), - title: .title, reason: "mentioned", url: .url, account: $account}), - (.data.discussions.nodes[] | select(.url) | select(.closed | not) - | select(.viewerSubscription != "UNSUBSCRIBED" and .viewerSubscription != "IGNORED") - | {repo: .repository.nameWithOwner, class: "fyi", type: "discussion", - title: .title, reason: "mentioned", url: .url, account: $account}) -]' - -chunks='' ok='' failed='' -for account in $accounts; do - fail=1 - while :; do # single-pass; break = this account failed - token=$(gh auth token --user "$account" 2>/dev/null) - [[ -n $token ]] || break - # Identity check — see design rules. Logins compare - # case-insensitively (GitHub treats them that way). - login=$(GH_TOKEN=$token gh api user --jq .login 2>/dev/null) - [[ -n $login ]] || break - [[ $(tr '[:upper:]' '[:lower:]' <<<"$login") == "$(tr '[:upper:]' '[:lower:]' <<<"$account")" ]] || break - review=$(fetch "$token" "search/issues?q=type:pr+state:open+review-requested:${account}&per_page=100" "$account" "$review_jq") || break - assigned=$(fetch "$token" "search/issues?q=state:open+assignee:${account}&per_page=100" "$account" "$assign_jq") || break - invites=$(fetch "$token" "user/repository_invitations?per_page=100" "$account" "$invite_jq") || break - resp=$(GH_TOKEN=$token gh api graphql \ - -f qi="mentions:${account} is:open" \ - -f qd="mentions:${account}" \ - -f query="$mention_gql" 2>/dev/null) || break - mentions=$(jq -c --arg account "$account" "$mention_jq" <<<"$resp" 2>/dev/null) || break - chunks+="$review"$'\n'"$assigned"$'\n'"$invites"$'\n'"$mentions"$'\n' - ok=1 fail='' - break - done - [[ -n $fail ]] && failed+="$account"$'\n' -done -[[ -n $ok ]] || exit 0 - -# Carry a failed account's items forward from the previous cache (repo is -# reconstructed from the map key; items written before account tags -# existed can't be attributed and just age out). -if [[ -n $failed && -r $inbox_file ]]; then - failed_json=$(jq -Rn '[inputs | select(length > 0)]' <<<"$failed" 2>/dev/null) - carried=$(jq -c --argjson failed "${failed_json:-[]}" ' - [.repos // {} | to_entries[] | .key as $r | .value.items[] - | select(.account as $a | $failed | index($a)) | . + {repo: $r}] - ' "$inbox_file" 2>/dev/null) && chunks+="$carried"$'\n' -fi - -# Merge the accounts' streams. todo sorts before fyi so unique_by — which -# keeps each URL's first occurrence — makes the obligation win when the -# same item is both (assigned PR that also mentions me), and an item -# visible to both accounts counts once. Zero items across the board still -# writes (an empty repos map is a true zero — the accounts answered). -tmp="$inbox_file.tmp" -if jq -s --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" ' - add - | sort_by(if .class == "todo" then 0 else 1 end) - | unique_by(.url) - | group_by(.repo) - | map({key: .[0].repo, value: { - todo: (map(select(.class == "todo")) | length), - fyi: (map(select(.class == "fyi")) | length), - items: map(del(.repo))}}) - | from_entries - | {updated: $now, repos: .} -' <<<"$chunks" >"$tmp" 2>/dev/null && [[ -s $tmp ]]; then - mv -f "$tmp" "$inbox_file" 2>/dev/null -else - rm -f "$tmp" 2>/dev/null -fi -exit 0 diff --git a/.claude/statusline.sh b/.claude/statusline.sh deleted file mode 100755 index bb58ce00aa1..00000000000 --- a/.claude/statusline.sh +++ /dev/null @@ -1,436 +0,0 @@ -#!/usr/bin/env bash - -# claude-statusline — Claude Code status line: -# (venv) (direnv:dir) org/repo[/subdir] on branch [+!?$] ↑N↓N pr N as @ghuser inbox N via Model (effort) ctx N% +N/-N -# Git status flags: + staged, ! unstaged, ? untracked, $ stashed. -# -# Design rule: every segment is optional and self-hiding. Each probe carries -# its own guard, so a machine missing git, a GitHub identity, lsof — even jq — -# renders whatever segments it can and never prints an error or exits non-zero. -# Colors come from the 16-color ANSI palette, so they track whatever color -# scheme the terminal uses. - -# jq is the one parsing dependency: without it we can't read the JSON Claude -# Code pipes in, so render an empty status line rather than an error. The -# install step is where a missing jq should be reported — a human is watching -# then; nobody is watching a statusline subprocess fail every second. -command -v jq >/dev/null 2>&1 || exit 0 - -input=$(cat) -cwd=$(jq -r '.workspace.current_dir // .cwd // empty' <<<"$input") -model=$(jq -r '.model.display_name // empty' <<<"$input") -effort=$(jq -r '.effort.level // empty' <<<"$input") -session_id=$(jq -r '.session_id // empty' <<<"$input") -project_dir=$(jq -r '.workspace.project_dir // .cwd // empty' <<<"$input") -added=$(jq -r '.cost.total_lines_added // 0' <<<"$input") -removed=$(jq -r '.cost.total_lines_removed // 0' <<<"$input") -ctx_pct=$(jq -r '.context_window.used_percentage // empty' <<<"$input") - -# Animation clock, one tick per second: the script is stateless across -# invocations, so anything animated (max rainbow, task spinner) keys its -# frame to the wall clock and advances on each refresh. Plain POSIX date — -# deliberately no gdate/coreutils dependency for sub-second ticks; the -# statusline mostly redraws once per second anyway. -anim_t=$(date +%s) - -# ANSI palette colors — the terminal's own scheme decides the actual hues -grey=$'\033[90m' red=$'\033[31m' green=$'\033[32m' -blue=$'\033[34m' magenta=$'\033[35m' cyan=$'\033[36m' -yellow=$'\033[33m' reset=$'\033[0m' - -# Spinner frames and glyph accents, selected by the argument the statusLine -# command in settings.json passes ("braille", "nerd"; anything else means -# plain ASCII). The style lives in settings, never in edits to this file, so -# every installed copy stays byte-identical to the shipped asset. The glyphs -# are literal UTF-8, not $'\u' escapes — macOS ships bash 3.2, which lacks -# them. nerd needs a patched font: spinner U+EE06–U+EE0B, branch U+E0A0, -# GitHub U+F09B, model U+F06A9, effort bolt U+F0E7, ctx gauge U+F04C5, -# venv python U+E73C, direnv leaf U+F06C, pull request U+F407, inbox bell -# U+F009A. Icons stand -# in for words, so -# nerd drops the grey "via"/"ctx"/"pr" labels rather than decorating them, -# and the venv/direnv segments drop their () text wrappers. braille renders -# in nearly any modern terminal. -case ${1-} in -braille) - frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') - branch_icon='' user_icon='@' model_icon='' effort_icon='' ctx_icon='' venv_icon='' direnv_icon='' pr_icon='' inbox_icon='' ;; -nerd) - frames=('' '' '' '' '' '') - branch_icon=' ' user_icon=' ' model_icon='󰚩 ' effort_icon=' ' ctx_icon='󰓅 ' venv_icon=' ' direnv_icon=' ' pr_icon=' ' inbox_icon='󰂚 ' ;; -*) - frames=('-' '\' '|' '/') - branch_icon='' user_icon='@' model_icon='' effort_icon='' ctx_icon='' venv_icon='' direnv_icon='' pr_icon='' inbox_icon='' ;; -esac - -out='' - -# venv segment — ACTIVATION-based: show only when $VIRTUAL_ENV is set and its -# dir still exists. A dormant .venv merely sitting in the project tree is not -# an active venv, so presence alone must not light it up; the -d guard hides a -# venv whose dir was deleted after activation. The statusline's env is frozen -# at Claude launch, so this reflects what was activated, not what's on disk. -if [[ -n ${VIRTUAL_ENV:-} && -d $VIRTUAL_ENV ]]; then - # .venv/venv/.env/env is uninformative; name the segment for the project dir - vname=${VIRTUAL_ENV##*/} - case $vname in .venv|venv|.env|env) vp=${VIRTUAL_ENV%/*}; vname=${vp##*/} ;; esac - if [[ -n $venv_icon ]]; then - out+="${magenta}${venv_icon}${vname}${reset} " - else - out+="${magenta}(${vname})${reset} " - fi -fi - -# direnv segment — PRESENCE-based, unlike venv: walk up from $cwd to the -# nearest .envrc. Presence is the right signal here (an .envrc means the tree -# uses direnv) and re-reading the tree every render self-heals when the file -# is deleted — the frozen $DIRENV_DIR env var would just go stale. -d=$cwd -while [[ -n $d && $d != / ]]; do - if [[ -e $d/.envrc ]]; then - if [[ -n $direnv_icon ]]; then - out+="${cyan}${direnv_icon}${d##*/}${reset} " - else - out+="${cyan}(direnv:${d##*/})${reset} " - fi - break - fi - d=${d%/*} -done - -# GitHub-inbox refresh. gh-inbox is a standalone checker shipped next to -# this script; it sweeps every logged-in gh account and writes -# ~/.cache/gh-inbox/inbox.json. The statusline never fetches inbox data -# itself, it only schedules the checker and reads its file. Same -# stamp-first throttle as the fetch/pr-count blocks below, at most once -# per 10 minutes so handled items clear promptly (~4 requests/account/ -# refresh — well inside the search API's 30/min). Sits outside the git -# block so the cache stays fresh even when cwd isn't a repo. Resolved as -# a sibling of this file with -x, not a PATH lookup — the statusline -# inherits whatever PATH Claude launched with, and the sibling is the -# checker that shipped with this exact version. Opt out with -# `git config statusline.inbox false`. -checker="${BASH_SOURCE[0]%/*}/gh-inbox" -if command -v gh >/dev/null 2>&1 && [[ -x $checker ]] && - [[ $(git -C "$cwd" config --type=bool --get statusline.inbox 2>/dev/null) != false ]] && - [[ -z $(find "$HOME/.claude" -maxdepth 1 -name statusline-inbox.stamp -mmin -10 2>/dev/null) ]] && - touch "$HOME/.claude/statusline-inbox.stamp" 2>/dev/null; then - ("$checker" >/dev/null 2>&1 /dev/null) -if [[ -n $toplevel ]]; then - # org/repo from the origin URL (works for ssh and https), else dir name - remote=$(git -C "$cwd" config --get remote.origin.url 2>/dev/null) - repo=${remote%.git} - repo=$(sed -E 's#^.*[:/]([^/]+/[^/]+)$#\1#' <<<"$repo") - [[ -z $repo ]] && repo=$(basename "$toplevel") - - # path inside the repo, if we're below the root - rel=${cwd#"$toplevel"} - - branch=$(git -C "$cwd" symbolic-ref --quiet --short HEAD 2>/dev/null || - git -C "$cwd" rev-parse --short HEAD 2>/dev/null) - - # +!?$ flags via one porcelain call (one subprocess, not three diffs). - # Each flag gets its own color — staged green, unstaged yellow, untracked - # blue, stashed magenta — so the state reads at a glance by hue, without - # parsing which punctuation made it into the brackets. - porcelain=$(git -C "$cwd" status --porcelain 2>/dev/null) - flags='' - cut -c1 <<<"$porcelain" | grep -q '[MADRCT]' && flags+="${green}+${reset}" - cut -c2 <<<"$porcelain" | grep -q '[MADRCT]' && flags+="${yellow}!${reset}" - grep -q '^??' <<<"$porcelain" && flags+="${blue}?${reset}" - git -C "$cwd" rev-parse --verify --quiet refs/stash >/dev/null && flags+="${magenta}\$${reset}" - - out+="${green}${repo}${rel}${reset}" - out+=" ${grey}on${reset} ${blue}${branch_icon}${branch}${reset}" - [[ -n $flags ]] && out+=" ${grey}[${reset}${flags}${grey}]${reset}" - - # Per-repo GitHub identity, resolved from git config and used by every - # GitHub API call below (fetch ordering, PR count) and the as-@user - # segment: `statusline.account` names the gh account this repo's calls - # run as (explicit override, same namespace as the fetch/prcount/inbox - # opt-outs); else `github.user`, the conventional per-repo identity key - # that includeIf-based work/personal schemes set automatically. Unset - # means gh's active account — single-account setups need no - # configuration and get gh's default behavior untouched. - acct=$(git -C "$cwd" config --get statusline.account 2>/dev/null) - [[ -z $acct ]] && acct=$(git -C "$cwd" config --get github.user 2>/dev/null) - - # The ↑↓ arrows below compare against origin's last-*fetched* state — the - # remote-tracking ref only moves on fetch, so on a machine that never - # fetches, "behind" reads 0 forever and nobody learns they should pull. - # Keep it fresh: at most once per 5 minutes, fetch origin in the - # background. The stamp file is touched *before* the attempt (and gates - # the attempt) so an offline machine retries on the same 5-minute cadence - # instead of every render. - # - # The fetch must never reach an ssh agent: BatchMode only suppresses - # password prompts — agent SIGNING requests still go through, and a - # 1Password-backed agent (IdentityAgent in ~/.ssh/config) answers each - # one with a biometric dialog. A background fetch every 5 minutes then - # becomes a Touch ID prompt loop, and every dismissed prompt fails the - # fetch and re-arms the next one. So GitHub remotes fetch over https - # instead, trying each gh-logged-in account's token until one works — - # the repo's resolved account first (the identity block above), since - # that account most likely sees the repo. The token rides in an auth - # header through - # GIT_CONFIG_* env vars so it never shows in `ps`; the explicit refspec - # updates the same refs/remotes/origin/* refs a `fetch origin` would. - # Everything else fetches over ssh with IdentityAgent=none: on-disk - # keys still work, agent-only keys fail silently and the counts just - # go stale. Opt out per repo or globally with - # `git config statusline.fetch false`. - git_dir=$(git -C "$cwd" rev-parse --absolute-git-dir 2>/dev/null) - if [[ -n $remote && -n $git_dir ]] && - [[ $(git -C "$cwd" config --type=bool --get statusline.fetch 2>/dev/null) != false ]] && - [[ -z $(find "$git_dir" -maxdepth 1 -name statusline-fetch -mmin -5 2>/dev/null) ]] && - touch "$git_dir/statusline-fetch" 2>/dev/null; then - if [[ $remote == *github* && $repo == */* ]] && command -v gh >/dev/null 2>&1; then - ( - # logged-in accounts — the keys of the users: map in - # hosts.yml (same indent-sensitive awk as gh-inbox); the - # repo's resolved account moves to the front of the line, - # the rest stay behind it as fallback resilience. Logins - # compare case-insensitively (GitHub treats them that way). - accounts=$(awk ' - /^github\.com:/ { in_host = 1; next } - in_host && /^[^[:space:]]/ { in_host = 0; in_users = 0 } - in_host && $1 == "users:" { in_users = 1; next } - in_users && /^ [^[:space:]]+:$/ { u = $1; sub(/:$/, "", u); print u; next } - in_users && /^ [^[:space:]]/ { in_users = 0 } - ' "$HOME/.config/gh/hosts.yml" 2>/dev/null) - acct_lc=$(tr '[:upper:]' '[:lower:]' <<<"$acct") - try='' - for a in $accounts; do - if [[ -n $acct_lc && $(tr '[:upper:]' '[:lower:]' <<<"$a") == "$acct_lc" ]]; then - try="$a $try" - else - try="$try $a" - fi - done - for a in ${try:-''}; do - token=$(gh auth token ${a:+--user "$a"} 2>/dev/null) - [[ -n $token ]] || continue - auth=$(printf 'x-access-token:%s' "$token" | base64 | tr -d '\n') - GIT_TERMINAL_PROMPT=0 \ - GIT_CONFIG_COUNT=1 \ - GIT_CONFIG_KEY_0='http.https://github.com/.extraheader' \ - GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $auth" \ - git -C "$cwd" fetch --quiet "https://github.com/${repo}.git" \ - '+refs/heads/*:refs/remotes/origin/*' >/dev/null 2>&1 && break - done - ) /dev/null 2>&1 - # for branches that were never pushed with tracking set up. - counts=$(git -C "$cwd" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || - git -C "$cwd" rev-list --left-right --count "origin/${branch}...HEAD" 2>/dev/null) - if [[ -n $counts ]]; then - behind=${counts%%[!0-9]*} ahead=${counts##*[!0-9]} - arrows='' - (( ahead )) && arrows+="${green}↑${ahead}${reset}" - (( behind )) && arrows+="${red}↓${behind}${reset}" - [[ -n $arrows ]] && out+=" ${arrows}" - fi - - # Open-PR count for the repo, from the GitHub search API via gh. The - # script renders every second, so the count is read from a cache file in - # the git dir and refreshed in the background at most once per 5 minutes - # — the same stamp-first throttle as the fetch above: touching the cache - # gates the attempt, so a failing gh (offline, rate-limited, repo not - # visible) retries on the cadence instead of every render, and the - # segment just goes stale or stays hidden. The query runs as the repo's - # resolved account (the identity block above), else gh's active account. - # Hidden at zero: the segment answers "are there open PRs?", so silence - # means no. Opt out per repo or globally with - # `git config statusline.prcount false`. - pr_file="$git_dir/statusline-prcount" - if [[ -n $git_dir && $remote == *github* && $repo == */* ]] && - command -v gh >/dev/null 2>&1 && - [[ $(git -C "$cwd" config --type=bool --get statusline.prcount 2>/dev/null) != false ]] && - [[ -z $(find "$git_dir" -maxdepth 1 -name statusline-prcount -mmin -5 2>/dev/null) ]] && - touch "$pr_file" 2>/dev/null; then - ( - # Trust no token: gh's keyring can hand back a DIFFERENT - # account's token, and a search as the wrong identity returns - # silently thin counts, not errors — so the token's identity - # is verified before use, and a mismatch skips this refresh - # (stale beats wrong; the stamp retries on cadence). A - # resolved account with NO token just isn't logged into gh — - # stale config, not a wrong identity — so the query falls - # back to the active account, unpinned, as if no account had - # resolved. Logins compare case-insensitively. - token='' - if [[ -n $acct ]]; then - token=$(gh auth token --user "$acct" 2>/dev/null) - if [[ -n $token ]]; then - login=$(GH_TOKEN=$token gh api user --jq .login 2>/dev/null) - [[ $(tr '[:upper:]' '[:lower:]' <<<"$login") == \ - "$(tr '[:upper:]' '[:lower:]' <<<"$acct")" ]] || exit 0 - fi - fi - env ${token:+"GH_TOKEN=$token"} \ - gh api "search/issues?q=repo:${repo}+type:pr+state:open" \ - --jq .total_count >"$pr_file.tmp" 2>/dev/null && - mv -f "$pr_file.tmp" "$pr_file" - ) /dev/null) - if [[ $prcount =~ ^[0-9]+$ ]] && (( prcount > 0 )); then - if [[ -n $pr_icon ]]; then - out+=" ${grey}${pr_icon}${reset}${blue}${prcount}${reset}" - else - out+=" ${grey}pr${reset} ${blue}${prcount}${reset}" - fi - fi - - # GitHub identity segment: the repo's resolved account (the identity - # block above — statusline.account, else github.user), so what this - # shows IS the identity the fetch and PR-count calls above run as. - # Fallback: the gh CLI's active account, read straight from hosts.yml — - # this script re-runs every second, so spawning `gh auth status` (slow) or - # `gh api user` (network) is off the table. The awk matches the host - # block's `user:` key exactly — hosts.yml also has a `users:` map of all - # logged-in accounts, which must not false-match. - gh_user=$acct - if [[ -z $gh_user && -r $HOME/.config/gh/hosts.yml ]]; then - gh_user=$(awk ' - /^github\.com:/ { in_host = 1; next } - in_host && /^[^[:space:]]/ { in_host = 0 } - in_host && $1 == "user:" { print $2; exit } - ' "$HOME/.config/gh/hosts.yml" 2>/dev/null) - fi - [[ -n $gh_user ]] && out+=" ${grey}as${reset} ${magenta}${user_icon}${gh_user}${reset}" - - # Inbox badge: items in THIS repo that need me, read from the gh-inbox - # cache the block above keeps fresh — never fetched here (the render - # path runs every second and must stay pure-read). Count = todo - # (review requests, assignments, invites; persist until resolved on - # GitHub) + fyi (open PRs/issues/discussions that @mention me; clear - # when the item closes or I unsubscribe from the thread). - # The hue says which kind, like the flags and arrows above: red means - # at least one todo (action owed), yellow means FYIs only (worth a - # look, nothing blocking). Hidden when zero, absent, or the cache is - # older than 25 min — a dead checker must not show a frozen count, and - # one find covers missing and stale in a single test. - inbox_file="${XDG_CACHE_HOME:-$HOME/.cache}/gh-inbox/inbox.json" - if [[ -n $(find "$inbox_file" -mmin -25 2>/dev/null) ]]; then - read -r inbox_todo inbox_fyi <<<"$(jq -r --arg r "$repo" \ - '.repos[$r] | if . then "\(.todo) \(.fyi)" else empty end' \ - "$inbox_file" 2>/dev/null)" - if [[ $inbox_todo =~ ^[0-9]+$ && $inbox_fyi =~ ^[0-9]+$ ]] && - (( inbox_todo + inbox_fyi > 0 )); then - inbox=$(( inbox_todo + inbox_fyi )) - if (( inbox_todo > 0 )); then inbox_color=$red; else inbox_color=$yellow; fi - if [[ -n $inbox_icon ]]; then - out+=" ${inbox_color}${inbox_icon}${inbox}${reset}" - else - out+=" ${grey}inbox${reset} ${inbox_color}${inbox}${reset}" - fi - fi - fi -else - out+="${green}${cwd/#$HOME/~}${reset}" -fi - -if [[ -n $model ]]; then - if [[ -n $model_icon ]]; then - out+=" ${cyan}${model_icon}${model}${reset}" - else - out+=" ${grey}via${reset} ${cyan}${model}${reset}" - fi -fi - -# reasoning effort (low/medium/high/xhigh/max); the field is absent when the -# model doesn't support the effort parameter, so the segment self-hides then. -# Hues mirror the /effort slider inside Claude Code (its theme tokens, mapped -# to the nearest ANSI color): low=warning yellow, medium=success green, -# high=permission blue, xhigh=autoAccept purple. max is rainbow-animated in -# the slider; here each letter gets its own hue and the word shifts one step -# per animation tick. -if [[ -n $effort ]]; then - case $effort in - low) effort_color=$yellow ;; - medium) effort_color=$green ;; - high) effort_color=$blue ;; - xhigh) effort_color=$magenta ;; - max) effort_color='' ;; - *) effort_color=$cyan ;; - esac - if [[ -n $effort_color ]]; then - effort_str="${effort_color}${effort}${reset}" - else - # max: per-letter rainbow. The icon takes the hue one step behind - # the first letter, so icon and word read as one moving gradient. - rainbow=("$red" "$yellow" "$green" "$cyan" "$blue" "$magenta") - effort_color=${rainbow[$(( (anim_t + 5) % 6 ))]} - effort_str='' - for (( i = 0; i < ${#effort}; i++ )); do - effort_str+="${rainbow[$(( (anim_t + i) % 6 ))]}${effort:$i:1}" - done - effort_str+=$reset - fi - if [[ -n $effort_icon ]]; then - out+=" ${effort_color}${effort_icon}${reset}${effort_str}" - else - out+=" ${grey}(${reset}${effort_str}${grey})${reset}" - fi -fi - -# context usage: green until 60%, yellow until 85%, red after (near auto-compact) -if [[ -n $ctx_pct ]]; then - pct=${ctx_pct%.*} - if (( pct >= 85 )); then ctx_color=$red - elif (( pct >= 60 )); then ctx_color=$yellow - else ctx_color=$green - fi - if [[ -n $ctx_icon ]]; then - out+=" ${grey}${ctx_icon}${reset}${ctx_color}${pct}%${reset}" - else - out+=" ${grey}ctx${reset} ${ctx_color}${pct}%${reset}" - fi -fi - -if (( added > 0 || removed > 0 )); then - out+=" ${green}+${added}${reset}${grey}/${reset}${red}-${removed}${reset}" -fi - -# Running-task spinner. The statusline JSON has no "work in progress" field, -# but every Bash tool call (foreground or background) and every subagent -# holds open an output file under the session's tasks dir until it finishes, -# so counting files some process holds open (lsof, deduped on the n field) -# equals the number of tasks running right now. This is observed behavior, -# not a documented interface — if the spinner stops appearing after a Claude -# Code update, re-check this path first. Needs statusLine.refreshInterval in -# settings.json: without it the statusline only re-renders on conversation -# events, which go quiet exactly when background work runs. -if [[ -n $session_id && -n $project_dir ]] && command -v lsof >/dev/null 2>&1; then - tasks_dir="/tmp/claude-$(id -u)/$(sed 's/[^A-Za-z0-9]/-/g' <<<"$project_dir")/${session_id}/tasks" - outputs=("$tasks_dir"/*.output) - if [[ -e ${outputs[0]} || -L ${outputs[0]} ]]; then - running=$(lsof -F n -- "${outputs[@]}" 2>/dev/null | sed -n 's/^n//p' | sort -u | grep -c .) - if (( running > 0 )); then - # on max effort the spinner borrows the effort segment's - # rainbow (already populated above) and cycles with it - spin_color=$yellow - [[ $effort == max ]] && spin_color=${rainbow[$(( anim_t % 6 ))]} - out+=" ${spin_color}${frames[$(( anim_t % ${#frames[@]} ))]}${reset}" - fi - fi -fi - -printf '%s' "$out" diff --git a/CLAUDE.md b/CLAUDE.md index d8cb795633b..4ea5cc9f59b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ CI runs tests on both `ubuntu-latest` and `macos-latest` via `.github/workflows/ - **`.macos`** — macOS `defaults write` settings; reads `$COMPUTER_NAME` env var for machine-specific naming - **`Brewfile`** — Homebrew formulae, casks, and Mac App Store apps - **`bin/`** — personal scripts added to `$PATH` +- **Claude Code statusline** — lives in `icanalytica/ica-skills` (skill `claude-statusline`), not here. Install once per machine with `/claude-statusline`; the plugin's session-start hook keeps the installed copies in `~/.claude` current after that. On a fresh machine, `.claude/settings.json`'s statusLine command is harmlessly dead until that one-time install. - **`init/`** — one-time setup scripts - **`theme/`** — Base16 Eighties color themes (darkened bg `#1a1a1a`) for iTerm2, Terminal.app, and Alfred; VSCode uses the `bsides.Theme-Base16-Eighties` extension installed by `.macos`. Terminal apps (bat, delta, fzf, k9s, vim) use `base16-256`/`base16-eighties` and inherit the iTerm palette.