claude-pace 0.7.0 → 0.7.1

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.
Files changed (2) hide show
  1. package/claude-pace.sh +167 -66
  2. package/package.json +1 -1
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
- C='\033[36m' G='\033[32m' Y='\033[33m' R='\033[31m' D='\033[2m' N='\033[0m'
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
- GC="/tmp/claude-sl-git-${DIR//[^a-zA-Z0-9]/_}"
70
- if _stale "$GC" 5; then
71
- if git -C "$DIR" rev-parse --git-dir >/dev/null 2>&1; then
72
- _BR=$(git -C "$DIR" --no-optional-locks branch --show-current 2>/dev/null)
73
- _FC=0 _AD=0 _DL=0
74
- while IFS=$'\t' read -r a d _; do
75
- # Skip binary files (reported as "-" instead of a number).
76
- [[ "$a" =~ ^[0-9]+$ ]] && {
77
- _FC=$((_FC + 1))
78
- _AD=$((_AD + a))
79
- _DL=$((_DL + d))
80
- }
81
- done < <(git -C "$DIR" --no-optional-locks diff HEAD --numstat 2>/dev/null)
82
- _TMP=$(mktemp /tmp/claude-sl-g-XXXXXX)
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,20 +198,16 @@ 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
- ((R5 > 0)) && {
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="/tmp/claude-sl-usage" UL="/tmp/claude-sl-usage.lock"
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).
@@ -147,32 +225,39 @@ else
147
225
  [ -n "$b" ] && jq -r '.claudeAiOauth.accessToken//empty' <<<"$b" 2>/dev/null
148
226
  }
149
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
+ tk=$(_get_token)
233
+ [ -n "$tk" ] || return 1
234
+ resp=$(curl -s --max-time 3 \
235
+ -H "Authorization: Bearer $tk" -H "anthropic-beta: oauth-2025-04-20" \
236
+ -H "Content-Type: application/json" \
237
+ "https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
238
+ IFS=$'\t' read -r U5 U7 XO XU XL RM5 RM7 < <(jq -r '
239
+ 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;
240
+ [(.five_hour.utilization|floor),(.seven_day.utilization|floor),
241
+ (if .extra_usage.is_enabled then 1 else 0 end),
242
+ (.extra_usage.used_credits//0|floor),(.extra_usage.monthly_limit//0|floor),
243
+ (.five_hour.resets_at|rmins//""),(.seven_day.resets_at|rmins//"")]|@tsv' \
244
+ <<<"$resp" 2>/dev/null) || return 1
245
+ }
246
+
150
247
  # ── _fetch_usage: background stale-while-revalidate fetch ──
151
248
  # Runs in a subshell (&) so the main process returns immediately with cached data.
152
- # On API failure, touches the cache file to reset the 300s TTL and avoid a
153
- # retry storm; placeholder "--" values leave the display unchanged.
249
+ # On API failure, writes placeholder values once so the UI stays stable and
250
+ # avoids repeated refresh attempts until the cache TTL expires.
154
251
  _fetch_usage() {
155
252
  (
156
253
  trap 'rm -f "$UL"' EXIT
157
- TK=$(_get_token)
158
- [ -z "$TK" ] && return
159
- RESP=$(curl -s --max-time 3 \
160
- -H "Authorization: Bearer $TK" -H "anthropic-beta: oauth-2025-04-20" \
161
- -H "Content-Type: application/json" \
162
- "https://api.anthropic.com/api/oauth/usage" 2>/dev/null)
163
- IFS=$'\t' read -r F5 S7 EX EU EL RM5 RM7 < <(jq -r '
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"
254
+ if _fetch_usage_api; then
255
+ _write_cache_record "$UC" "$U5" "$U7" "$XO" "$XU" "$XL" "$RM5" "$RM7"
256
+ else
257
+ if [ ! -f "$UC" ] || [[ $(head -c2 "$UC") == -- ]]; then
258
+ _write_cache_record "$UC" "--" "--" "0" "0" "0" "" ""
259
+ fi
260
+ fi
176
261
  ) &
177
262
  }
178
263
 
@@ -180,7 +265,7 @@ else
180
265
  # `set -o noclobber` makes `>` fail atomically if the file already exists,
181
266
  # providing a lock without external tools. The stale-lock check (10s) ensures
182
267
  # a crashed worker can't block refreshes indefinitely.
183
- if _stale "$UC" 300; then
268
+ if [[ "$CACHE_OK" == "1" ]] && _stale "$UC" 300; then
184
269
  if (
185
270
  set -o noclobber
186
271
  echo $$ >"$UL"
@@ -200,18 +285,34 @@ else
200
285
  # (in whole minutes) since the file was written to keep the countdown accurate
201
286
  # between 300s refresh cycles without a network call.
202
287
  U5="--" U7="--" XO=0 XU=0 XL=0 RM5="" RM7=""
203
- [ -f "$UC" ] && IFS='|' read -r U5 U7 XO XU XL RM5 RM7 <"$UC"
204
- U5=${U5%%.*} U7=${U7%%.*} XU=${XU%%.*} XL=${XL%%.*}
205
- if [[ "$RM5" =~ ^[0-9]+$ ]] && [ -f "$UC" ]; then
206
- _CA=$((NOW - $(stat -f%m "$UC" 2>/dev/null || stat -c%Y "$UC" 2>/dev/null || echo "$NOW")))
207
- RM5=$((RM5 - _CA / 60))
208
- ((RM5 < 0)) && RM5=0
209
- [[ "$RM7" =~ ^[0-9]+$ ]] && {
210
- RM7=$((RM7 - _CA / 60))
211
- ((RM7 < 0)) && RM7=0
212
- }
288
+ if [[ "$CACHE_OK" == "1" ]]; then
289
+ if _load_cache_record_file "$UC"; then
290
+ U5=${CACHE_FIELDS[0]:---}
291
+ U7=${CACHE_FIELDS[1]:---}
292
+ XO=${CACHE_FIELDS[2]:-0}
293
+ XU=${CACHE_FIELDS[3]:-0}
294
+ XL=${CACHE_FIELDS[4]:-0}
295
+ RM5=${CACHE_FIELDS[5]:-}
296
+ RM7=${CACHE_FIELDS[6]:-}
297
+ fi
298
+ if [[ "$RM5" =~ ^[0-9]+$ ]] && [ -f "$UC" ]; then
299
+ _CA=$((NOW - $(stat -f%m "$UC" 2>/dev/null || stat -c%Y "$UC" 2>/dev/null || echo "$NOW")))
300
+ RM5=$((RM5 - _CA / 60))
301
+ ((RM5 < 0)) && RM5=0
302
+ [[ "$RM7" =~ ^[0-9]+$ ]] && {
303
+ RM7=$((RM7 - _CA / 60))
304
+ ((RM7 < 0)) && RM7=0
305
+ }
306
+ fi
307
+ [ ! -f "$UC" ] && SHOW_COST=1
308
+ elif ! _fetch_usage_api; then
309
+ SHOW_COST=1
213
310
  fi
214
- [ ! -f "$UC" ] && SHOW_COST=1
311
+ U5=${U5%%.*} U7=${U7%%.*} XU=${XU%%.*} XL=${XL%%.*}
312
+ # Reject cache corruption or malformed API data before arithmetic formatting.
313
+ [[ "$XO" =~ ^[01]$ ]] || XO=0
314
+ [[ "$XU" =~ ^[0-9]+$ ]] || XU=0
315
+ [[ "$XL" =~ ^[0-9]+$ ]] || XL=0
215
316
  # ── End API fallback ──
216
317
  fi
217
318
 
@@ -265,11 +366,11 @@ L2="${BC}${BAR}${N} ${PCT}% ${CL}${PAD2} ${D}|${N} 5h $(_usage "$U5" "$RM5" 300
265
366
  # Extra usage: only when enabled and has actual spending (API fallback only)
266
367
  [ "$XO" = 1 ] && ((XU > 0)) &&
267
368
  printf -v _XS " ${Y}\$%d.%02d${N}/\$%d.%02d" $((XU / 100)) $((XU % 100)) $((XL / 100)) $((XL % 100)) && L2+="$_XS"
268
- # Session cost: only when /tmp/claude-sl-usage does not exist
369
+ # Session cost: only when this run has no readable usage cache data.
269
370
  if [[ "$SHOW_COST" == "1" ]]; then
270
371
  printf -v _CS "\$%.2f" "$COST" 2>/dev/null
271
372
  [[ "$_CS" != "\$0.00" ]] && L2+=" $_CS"
272
373
  fi
273
374
 
274
- echo -e "$L1"
275
- echo -e "$L2"
375
+ printf '%s\n' "$L1"
376
+ printf '%s\n' "$L2"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-pace",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "A statusline for Claude Code. Pure Bash + jq, single file.",
5
5
  "bin": {
6
6
  "claude-pace": "cli.js"