claude-pace 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/claude-pace.sh +179 -68
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,11 +80,17 @@ Claude Code polls the statusline every ~300ms:
|
|
|
80
80
|
|------|--------|-------|
|
|
81
81
|
| Model, context, cost | stdin JSON (single `jq` call) | None needed |
|
|
82
82
|
| Quota (5h, 7d, pace) | stdin `rate_limits` (CC >= 2.1.80) | None needed (real-time) |
|
|
83
|
-
| Quota fallback | Anthropic Usage API (CC < 2.1.80) |
|
|
84
|
-
| Git branch + diff | `git` commands |
|
|
83
|
+
| Quota fallback | Anthropic Usage API (CC < 2.1.80) | Private cache dir, 300s TTL, async background refresh |
|
|
84
|
+
| Git branch + diff | `git` commands | Private cache dir, 5s TTL |
|
|
85
85
|
|
|
86
86
|
On Claude Code >= 2.1.80, usage data comes directly from stdin. No network calls. On older versions, it falls back to the Usage API in a background subshell so the statusline never blocks.
|
|
87
87
|
|
|
88
|
+
Cache files live in a private per-user directory (`$XDG_RUNTIME_DIR/claude-pace` or `~/.cache/claude-pace`, mode 700). All cache reads are validated before use. No files are ever written to shared `/tmp`.
|
|
89
|
+
|
|
90
|
+
## Also by the Author
|
|
91
|
+
|
|
92
|
+
[**diffpane**](https://github.com/Astro-Han/diffpane) - Real-time TUI diff viewer for AI coding agents. See what Claude Code changes as it happens.
|
|
93
|
+
|
|
88
94
|
## License
|
|
89
95
|
|
|
90
96
|
MIT
|
package/claude-pace.sh
CHANGED
|
@@ -18,8 +18,88 @@ command -v jq >/dev/null || {
|
|
|
18
18
|
|
|
19
19
|
# ── Colors & Utilities ──
|
|
20
20
|
# C=Cyan G=Green Y=Yellow R=Red D=Dim N=Normal (reset)
|
|
21
|
-
|
|
21
|
+
# Store real escape bytes so final output does not need echo -e interpretation.
|
|
22
|
+
C=$'\033[36m' G=$'\033[32m' Y=$'\033[33m' R=$'\033[31m' D=$'\033[2m' N=$'\033[0m'
|
|
23
|
+
# Cache records use ASCII Unit Separator so legal Git ref names cannot split
|
|
24
|
+
# serialized fields and empty values survive round-trips through read.
|
|
25
|
+
SEP=$'\037'
|
|
22
26
|
NOW=$(date +%s)
|
|
27
|
+
# Returns true when the candidate cache dir is a real directory owned by the
|
|
28
|
+
# current user, writable, and not a symlink into a foreign-controlled path.
|
|
29
|
+
_cache_dir_ok() { [ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] && [ -w "$1" ]; }
|
|
30
|
+
# Reads one cache record into CACHE_FIELDS, supporting the current separator
|
|
31
|
+
# and the legacy pipe format used by older cache files.
|
|
32
|
+
_read_cache_record() {
|
|
33
|
+
local line="$1" delim rest field
|
|
34
|
+
CACHE_FIELDS=()
|
|
35
|
+
if [[ "$line" == *"$SEP"* ]]; then
|
|
36
|
+
delim="$SEP"
|
|
37
|
+
else
|
|
38
|
+
delim='|'
|
|
39
|
+
fi
|
|
40
|
+
rest="$line"
|
|
41
|
+
while [[ "$rest" == *"$delim"* ]]; do
|
|
42
|
+
field=${rest%%"$delim"*}
|
|
43
|
+
CACHE_FIELDS+=("$field")
|
|
44
|
+
rest=${rest#*"$delim"}
|
|
45
|
+
done
|
|
46
|
+
CACHE_FIELDS+=("$rest")
|
|
47
|
+
}
|
|
48
|
+
# Loads and parses one cache file into CACHE_FIELDS.
|
|
49
|
+
_load_cache_record_file() {
|
|
50
|
+
local path="$1" line=""
|
|
51
|
+
[ -f "$path" ] || return 1
|
|
52
|
+
IFS= read -r line <"$path" || line=""
|
|
53
|
+
_read_cache_record "$line"
|
|
54
|
+
}
|
|
55
|
+
# Writes one cache record atomically. If mktemp fails, the caller skips the
|
|
56
|
+
# cache update and keeps serving live data for this run.
|
|
57
|
+
_write_cache_record() {
|
|
58
|
+
local path="$1" tmp dir
|
|
59
|
+
shift
|
|
60
|
+
dir=${path%/*}
|
|
61
|
+
tmp=$(mktemp "${dir}/claude-sl-tmp-XXXXXX" 2>/dev/null || true)
|
|
62
|
+
[ -n "$tmp" ] || return 1
|
|
63
|
+
(
|
|
64
|
+
IFS="$SEP"
|
|
65
|
+
printf '%s\n' "$*"
|
|
66
|
+
) >"$tmp" && mv "$tmp" "$path"
|
|
67
|
+
}
|
|
68
|
+
# Computes remaining whole minutes until a future epoch. Missing or expired
|
|
69
|
+
# timestamps return an empty string so callers can skip countdown formatting.
|
|
70
|
+
_minutes_until() {
|
|
71
|
+
local epoch="$1" mins
|
|
72
|
+
[[ "$epoch" =~ ^[0-9]+$ ]] && ((epoch > 0)) || return
|
|
73
|
+
mins=$(((epoch - NOW) / 60))
|
|
74
|
+
((mins < 0)) && mins=0
|
|
75
|
+
printf '%s\n' "$mins"
|
|
76
|
+
}
|
|
77
|
+
# Collects live Git metadata for DIR. On non-repos, leaves defaults in place
|
|
78
|
+
# and returns non-zero so callers can decide whether to cache the empty result.
|
|
79
|
+
_collect_git_info() {
|
|
80
|
+
BR="" FC=0 AD=0 DL=0
|
|
81
|
+
git -C "$DIR" rev-parse --git-dir >/dev/null 2>&1 || return 1
|
|
82
|
+
BR=$(git -C "$DIR" --no-optional-locks branch --show-current 2>/dev/null)
|
|
83
|
+
while IFS=$'\t' read -r a d _; do
|
|
84
|
+
# Skip binary files (reported as "-" instead of a number).
|
|
85
|
+
[[ "$a" =~ ^[0-9]+$ ]] || continue
|
|
86
|
+
FC=$((FC + 1))
|
|
87
|
+
AD=$((AD + a))
|
|
88
|
+
DL=$((DL + d))
|
|
89
|
+
done < <(git -C "$DIR" --no-optional-locks diff HEAD --numstat 2>/dev/null)
|
|
90
|
+
}
|
|
91
|
+
# Cache only inside a user-owned, non-symlinked directory. If no safe root is
|
|
92
|
+
# available, disable caching for this run instead of falling back to shared /tmp.
|
|
93
|
+
_CD="" CACHE_OK=0
|
|
94
|
+
for _BASE in "${XDG_RUNTIME_DIR:-}" "${HOME}/.cache"; do
|
|
95
|
+
[ -n "$_BASE" ] || continue
|
|
96
|
+
_CAND="${_BASE%/}/claude-pace"
|
|
97
|
+
[ -e "$_CAND" ] || mkdir -p -m 700 "$_CAND" 2>/dev/null || continue
|
|
98
|
+
_cache_dir_ok "$_CAND" || continue
|
|
99
|
+
_CD="$_CAND"
|
|
100
|
+
CACHE_OK=1
|
|
101
|
+
break
|
|
102
|
+
done
|
|
23
103
|
# Returns true (exit 0) when file is missing or older than $2 seconds.
|
|
24
104
|
_stale() { [ ! -f "$1" ] || [ $((NOW - $(stat -f%m "$1" 2>/dev/null || stat -c%Y "$1" 2>/dev/null || echo 0))) -gt "$2" ]; }
|
|
25
105
|
|
|
@@ -66,26 +146,28 @@ for ((i = F; i < 10; i++)); do BAR+='░'; done
|
|
|
66
146
|
# ── Git Info (5s cache, atomic write) ──
|
|
67
147
|
# Cache key encodes DIR so concurrent sessions in different repos don't clash.
|
|
68
148
|
# Atomic write: write to a temp file first, then mv to avoid partial reads.
|
|
69
|
-
|
|
70
|
-
if
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
echo "${_BR}|${_FC}|${_AD}|${_DL}" >"$_TMP" && mv "$_TMP" "$GC"
|
|
84
|
-
else
|
|
85
|
-
echo "|||" >"$GC"
|
|
149
|
+
BR="" FC=0 AD=0 DL=0
|
|
150
|
+
if [[ "$CACHE_OK" == "1" ]]; then
|
|
151
|
+
GC="${_CD}/claude-sl-git-${DIR//[^a-zA-Z0-9]/_}"
|
|
152
|
+
if _stale "$GC" 5; then
|
|
153
|
+
if _collect_git_info; then
|
|
154
|
+
_write_cache_record "$GC" "$BR" "$FC" "$AD" "$DL"
|
|
155
|
+
else
|
|
156
|
+
_write_cache_record "$GC" "" "" "" ""
|
|
157
|
+
fi
|
|
158
|
+
elif _load_cache_record_file "$GC"; then
|
|
159
|
+
BR=${CACHE_FIELDS[0]:-}
|
|
160
|
+
FC=${CACHE_FIELDS[1]:-}
|
|
161
|
+
AD=${CACHE_FIELDS[2]:-}
|
|
162
|
+
DL=${CACHE_FIELDS[3]:-}
|
|
86
163
|
fi
|
|
164
|
+
# Reject cache corruption before arithmetic or terminal output formatting.
|
|
165
|
+
[[ "$FC" =~ ^[0-9]+$ ]] || FC=0
|
|
166
|
+
[[ "$AD" =~ ^[0-9]+$ ]] || AD=0
|
|
167
|
+
[[ "$DL" =~ ^[0-9]+$ ]] || DL=0
|
|
168
|
+
else
|
|
169
|
+
_collect_git_info || true
|
|
87
170
|
fi
|
|
88
|
-
IFS='|' read -r BR FC AD DL <"$GC" 2>/dev/null
|
|
89
171
|
|
|
90
172
|
# ── Project Name + Line 1 Right Section ──
|
|
91
173
|
# Extract project name. Worktree: save repo name explicitly.
|
|
@@ -116,26 +198,22 @@ SHOW_COST=0
|
|
|
116
198
|
if [[ "$HAS_RL" == "1" ]]; then
|
|
117
199
|
# Stdin path: real-time, no network. U5/U7 already set by jq read above.
|
|
118
200
|
# Guard: resets_at=0 means field missing, leave RM empty so _pace/_rc skip it
|
|
119
|
-
RM5
|
|
120
|
-
(
|
|
121
|
-
RM5=$(((R5 - NOW) / 60))
|
|
122
|
-
((RM5 < 0)) && RM5=0
|
|
123
|
-
}
|
|
124
|
-
RM7=""
|
|
125
|
-
((R7 > 0)) && {
|
|
126
|
-
RM7=$(((R7 - NOW) / 60))
|
|
127
|
-
((RM7 < 0)) && RM7=0
|
|
128
|
-
}
|
|
201
|
+
RM5=$(_minutes_until "$R5")
|
|
202
|
+
RM7=$(_minutes_until "$R7")
|
|
129
203
|
# Extra usage (XO/XU/XL) only available via API fallback; stdin lacks this data
|
|
130
204
|
else
|
|
131
205
|
# ── API fallback (remove when CC <2.1.80 no longer supported) ──
|
|
132
|
-
UC="
|
|
206
|
+
UC="" UL=""
|
|
207
|
+
[[ "$CACHE_OK" == "1" ]] && {
|
|
208
|
+
UC="${_CD}/claude-sl-usage"
|
|
209
|
+
UL="${_CD}/claude-sl-usage.lock"
|
|
210
|
+
}
|
|
133
211
|
|
|
134
212
|
# ── _get_token: credential source priority ──
|
|
135
213
|
# Check in order: env var → macOS Keychain → credentials file → secret-tool (Linux).
|
|
136
214
|
_get_token() {
|
|
137
215
|
[ -n "$CLAUDE_CODE_OAUTH_TOKEN" ] && {
|
|
138
|
-
|
|
216
|
+
printf '%s' "$CLAUDE_CODE_OAUTH_TOKEN"
|
|
139
217
|
return
|
|
140
218
|
}
|
|
141
219
|
local b=""
|
|
@@ -144,35 +222,52 @@ else
|
|
|
144
222
|
[ -z "$b" ] && [ -f ~/.claude/.credentials.json ] && b=$(<~/.claude/.credentials.json)
|
|
145
223
|
[ -z "$b" ] && command -v secret-tool >/dev/null &&
|
|
146
224
|
b=$(timeout 2 secret-tool lookup service "Claude Code-credentials" 2>/dev/null)
|
|
147
|
-
[ -n "$b" ] && jq -
|
|
225
|
+
[ -n "$b" ] && jq -j '.claudeAiOauth.accessToken//empty' <<<"$b" 2>/dev/null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# ── _fetch_usage_api: direct API read into usage globals ──
|
|
229
|
+
# Used by both the cached background refresh path and the no-cache fallback.
|
|
230
|
+
_fetch_usage_api() {
|
|
231
|
+
local tk resp
|
|
232
|
+
# Command substitution strips trailing newlines. Append a sentinel byte only
|
|
233
|
+
# on success so malformed tokens with a trailing LF remain detectable here.
|
|
234
|
+
tk=$(_get_token && printf '\001') || return 1
|
|
235
|
+
[[ "$tk" == *$'\001' ]] || return 1
|
|
236
|
+
tk=${tk%$'\001'}
|
|
237
|
+
[ -n "$tk" ] || return 1
|
|
238
|
+
# OAuth bearer tokens must remain a single header line. Reject malformed
|
|
239
|
+
# credentials up front instead of letting curl parse injected CR/LF bytes.
|
|
240
|
+
case "$tk" in *$'\n'* | *$'\r'*) return 1 ;; esac
|
|
241
|
+
# Feed headers through process substitution so the bearer token stays out
|
|
242
|
+
# of curl argv while preserving literal bytes like quotes and backslashes.
|
|
243
|
+
resp=$(curl -s --max-time 3 \
|
|
244
|
+
-H @<(printf 'Authorization: Bearer %s\n' "$tk"
|
|
245
|
+
printf '%s\n' 'anthropic-beta: oauth-2025-04-20'
|
|
246
|
+
printf '%s\n' 'Content-Type: application/json') \
|
|
247
|
+
"https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
|
|
248
|
+
IFS=$'\t' read -r U5 U7 XO XU XL RM5 RM7 < <(jq -r '
|
|
249
|
+
def rmins: if . and . != "" then (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | fromdateiso8601) - (now|floor) | ./60|floor | if .<0 then 0 else . end else null end;
|
|
250
|
+
[(.five_hour.utilization|floor),(.seven_day.utilization|floor),
|
|
251
|
+
(if .extra_usage.is_enabled then 1 else 0 end),
|
|
252
|
+
(.extra_usage.used_credits//0|floor),(.extra_usage.monthly_limit//0|floor),
|
|
253
|
+
(.five_hour.resets_at|rmins//""),(.seven_day.resets_at|rmins//"")]|@tsv' \
|
|
254
|
+
<<<"$resp" 2>/dev/null) || return 1
|
|
148
255
|
}
|
|
149
256
|
|
|
150
257
|
# ── _fetch_usage: background stale-while-revalidate fetch ──
|
|
151
258
|
# Runs in a subshell (&) so the main process returns immediately with cached data.
|
|
152
|
-
# On API failure,
|
|
153
|
-
#
|
|
259
|
+
# On API failure, writes placeholder values once so the UI stays stable and
|
|
260
|
+
# avoids repeated refresh attempts until the cache TTL expires.
|
|
154
261
|
_fetch_usage() {
|
|
155
262
|
(
|
|
156
263
|
trap 'rm -f "$UL"' EXIT
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def rmins: if . and . != "" then (sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | fromdateiso8601) - (now|floor) | ./60|floor | if .<0 then 0 else . end else null end;
|
|
165
|
-
[(.five_hour.utilization|floor),(.seven_day.utilization|floor),
|
|
166
|
-
(if .extra_usage.is_enabled then 1 else 0 end),
|
|
167
|
-
(.extra_usage.used_credits//0|floor),(.extra_usage.monthly_limit//0|floor),
|
|
168
|
-
(.five_hour.resets_at|rmins//""),(.seven_day.resets_at|rmins//"")]|@tsv' \
|
|
169
|
-
<<<"$RESP" 2>/dev/null) || {
|
|
170
|
-
[ ! -f "$UC" ] || [[ $(head -c2 "$UC") == -- ]] && echo "--|--|0|0|0||" >"$UC"
|
|
171
|
-
touch "$UC"
|
|
172
|
-
return
|
|
173
|
-
}
|
|
174
|
-
TMP=$(mktemp /tmp/claude-sl-u-XXXXXX)
|
|
175
|
-
echo "${F5}|${S7}|${EX}|${EU}|${EL}|${RM5}|${RM7}" >"$TMP" && mv "$TMP" "$UC"
|
|
264
|
+
if _fetch_usage_api; then
|
|
265
|
+
_write_cache_record "$UC" "$U5" "$U7" "$XO" "$XU" "$XL" "$RM5" "$RM7"
|
|
266
|
+
else
|
|
267
|
+
if [ ! -f "$UC" ] || [[ $(head -c2 "$UC") == -- ]]; then
|
|
268
|
+
_write_cache_record "$UC" "--" "--" "0" "0" "0" "" ""
|
|
269
|
+
fi
|
|
270
|
+
fi
|
|
176
271
|
) &
|
|
177
272
|
}
|
|
178
273
|
|
|
@@ -180,7 +275,7 @@ else
|
|
|
180
275
|
# `set -o noclobber` makes `>` fail atomically if the file already exists,
|
|
181
276
|
# providing a lock without external tools. The stale-lock check (10s) ensures
|
|
182
277
|
# a crashed worker can't block refreshes indefinitely.
|
|
183
|
-
if _stale "$UC" 300; then
|
|
278
|
+
if [[ "$CACHE_OK" == "1" ]] && _stale "$UC" 300; then
|
|
184
279
|
if (
|
|
185
280
|
set -o noclobber
|
|
186
281
|
echo $$ >"$UL"
|
|
@@ -200,18 +295,34 @@ else
|
|
|
200
295
|
# (in whole minutes) since the file was written to keep the countdown accurate
|
|
201
296
|
# between 300s refresh cycles without a network call.
|
|
202
297
|
U5="--" U7="--" XO=0 XU=0 XL=0 RM5="" RM7=""
|
|
203
|
-
[
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
298
|
+
if [[ "$CACHE_OK" == "1" ]]; then
|
|
299
|
+
if _load_cache_record_file "$UC"; then
|
|
300
|
+
U5=${CACHE_FIELDS[0]:---}
|
|
301
|
+
U7=${CACHE_FIELDS[1]:---}
|
|
302
|
+
XO=${CACHE_FIELDS[2]:-0}
|
|
303
|
+
XU=${CACHE_FIELDS[3]:-0}
|
|
304
|
+
XL=${CACHE_FIELDS[4]:-0}
|
|
305
|
+
RM5=${CACHE_FIELDS[5]:-}
|
|
306
|
+
RM7=${CACHE_FIELDS[6]:-}
|
|
307
|
+
fi
|
|
308
|
+
if [[ "$RM5" =~ ^[0-9]+$ ]] && [ -f "$UC" ]; then
|
|
309
|
+
_CA=$((NOW - $(stat -f%m "$UC" 2>/dev/null || stat -c%Y "$UC" 2>/dev/null || echo "$NOW")))
|
|
310
|
+
RM5=$((RM5 - _CA / 60))
|
|
311
|
+
((RM5 < 0)) && RM5=0
|
|
312
|
+
[[ "$RM7" =~ ^[0-9]+$ ]] && {
|
|
313
|
+
RM7=$((RM7 - _CA / 60))
|
|
314
|
+
((RM7 < 0)) && RM7=0
|
|
315
|
+
}
|
|
316
|
+
fi
|
|
317
|
+
[ ! -f "$UC" ] && SHOW_COST=1
|
|
318
|
+
elif ! _fetch_usage_api; then
|
|
319
|
+
SHOW_COST=1
|
|
213
320
|
fi
|
|
214
|
-
|
|
321
|
+
U5=${U5%%.*} U7=${U7%%.*} XU=${XU%%.*} XL=${XL%%.*}
|
|
322
|
+
# Reject cache corruption or malformed API data before arithmetic formatting.
|
|
323
|
+
[[ "$XO" =~ ^[01]$ ]] || XO=0
|
|
324
|
+
[[ "$XU" =~ ^[0-9]+$ ]] || XU=0
|
|
325
|
+
[[ "$XL" =~ ^[0-9]+$ ]] || XL=0
|
|
215
326
|
# ── End API fallback ──
|
|
216
327
|
fi
|
|
217
328
|
|
|
@@ -265,11 +376,11 @@ L2="${BC}${BAR}${N} ${PCT}% ${CL}${PAD2} ${D}|${N} 5h $(_usage "$U5" "$RM5" 300
|
|
|
265
376
|
# Extra usage: only when enabled and has actual spending (API fallback only)
|
|
266
377
|
[ "$XO" = 1 ] && ((XU > 0)) &&
|
|
267
378
|
printf -v _XS " ${Y}\$%d.%02d${N}/\$%d.%02d" $((XU / 100)) $((XU % 100)) $((XL / 100)) $((XL % 100)) && L2+="$_XS"
|
|
268
|
-
# Session cost: only when
|
|
379
|
+
# Session cost: only when this run has no readable usage cache data.
|
|
269
380
|
if [[ "$SHOW_COST" == "1" ]]; then
|
|
270
381
|
printf -v _CS "\$%.2f" "$COST" 2>/dev/null
|
|
271
382
|
[[ "$_CS" != "\$0.00" ]] && L2+=" $_CS"
|
|
272
383
|
fi
|
|
273
384
|
|
|
274
|
-
|
|
275
|
-
|
|
385
|
+
printf '%s\n' "$L1"
|
|
386
|
+
printf '%s\n' "$L2"
|