Repository navigation
Expand file tree
/
Copy pathclaude-current
More file actions
executable file
·821 lines (770 loc) · 30.3 KB
/
Copy pathclaude-current
File metadata and controls
executable file
·821 lines (770 loc) · 30.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
#!/usr/bin/env bash
# claude-current: the Claude Code executable a launch should start. That is
# the newest version npm publishes, verified with `--version`, and it is named
# by an ABSOLUTE PATH. `claude` is never resolved through PATH.
#
# Installed on PATH by `openRepoTools --install`, beside `lane-start`, which
# calls it.
#
# Why it exists (opensoft/workBenches#119, Brett Heap's rulings of 2026-09-29):
# a lane started on an old Claude Code, and nothing noticed. Two installs
# competed on PATH, the image's copy frozen at the image build and the user's
# own npm copy, and a session that had run 2.1.283 for twenty hours lacked a
# model a fresh `claude` listed. The home ruling puts the resolver here,
# installed by `openRepoTools --install`. Point 2 is verbatim "for 2, we can
# run update on every start, this ensures we have the latest models", so every
# launch runs this check.
#
# USAGE
# claude-current [--porcelain] [--offline]
# claude-current --help
#
# WHAT IT DOES
# 1. Reads the published version with `npm view @anthropic-ai/claude-code
# version`, bounded by $CLAUDE_CURRENT_TIMEOUT seconds (default 10).
# --offline skips this step and step 3.
# 2. Reads each installed candidate's `--version`, by absolute path, in this
# order:
# - the newest native version in $CLAUDE_CURRENT_NATIVE_DIR (default
# ~/.local/share/claude/versions), by its x.y.z name, whose
# `--version` answers: a newer file that does not run gives way to
# the next one that does, and is not read again in the same run
# unless an update or a wait for the lock may have changed it;
# - <user npm prefix>/bin/claude. The prefix is $CLAUDE_CURRENT_NPM_PREFIX,
# else $NPM_CONFIG_PREFIX, else $npm_config_prefix, else the
# `prefix=` line of ~/.npmrc, else ~/.npm-global. A relative one is
# taken under $HOME;
# - ~/.local/bin/claude;
# - each entry of $CLAUDE_CURRENT_SYSTEM_CANDIDATES (default
# /usr/local/bin/claude:/usr/bin/claude, the image's copies).
# A path that resolves to one already read is read once, and the first
# spelling, the user-writable one, is kept.
# 3. When every candidate is behind the published version, or there is none,
# it updates the user-writable install under a lock and reads again. A
# native install gets `claude update`. When that does not reach the
# published version, or there is no native install, it runs
# `npm install -g --prefix <user prefix> @anthropic-ai/claude-code@<published>`
# and then the package's own install.cjs if `--version` still differs,
# because npm 12 skips the package's native-binary hook. A second launch
# waits on the lock and reads again before it updates anything itself.
# 4. Picks the first candidate whose version EQUALS the published one
# (verified). Else it picks the newest candidate AHEAD of it (ahead).
# Else it refuses (stale), unless CLAUDE_ALLOW_STALE=1, when it picks the
# newest (stale). When npm could not be read, it picks the newest
# candidate (unverified).
#
# OUTPUT
# stdout the absolute path, one line. With --porcelain, exactly four
# lines: path=<absolute path>, version=<x.y.z>,
# published=<x.y.z, or empty>, and
# status=<verified|ahead|unverified|stale>. A refusal prints nothing
# on stdout.
# stderr one line saying what was chosen, for example
# "claude-current: claude 2.1.284 (verified against npm 2.1.284) at <path>".
# An update prints its own progress there too.
#
# EXIT
# 0 resolved: verified, ahead or unverified, and stale only under
# CLAUDE_ALLOW_STALE=1
# 1 no runnable Claude Code at all
# 2 refused: every candidate is still behind npm after the update attempt
# 64 usage: an argument it does not know, or a CLAUDE_CURRENT_* bound that
# is not a whole number of seconds. Kept apart from 1, which a launcher
# reports as "no Claude Code" (opensoft/workBenches#121)
#
# ENVIRONMENT
# CLAUDE_ALLOW_STALE=1 launch a stale candidate anyway, and say so
# CLAUDE_CURRENT_NPM the npm to run (default: npm)
# CLAUDE_CURRENT_NODE the node that runs install.cjs (default: node)
# CLAUDE_CURRENT_TIMEOUT seconds for `npm view` (default 10)
# CLAUDE_CURRENT_VERSION_TIMEOUT seconds for each `--version` (default 10)
# CLAUDE_CURRENT_UPDATE_TIMEOUT seconds for each update command (default 300)
# CLAUDE_CURRENT_LOCK_WAIT seconds to wait for another launch's update
# (default 330)
# CLAUDE_CURRENT_CACHE_DIR where the lock lives (default
# ${XDG_CACHE_HOME:-~/.cache}/openrepotools)
# Relative paths are taken under $HOME.
# CLAUDE_CURRENT_NPM_PREFIX the user npm prefix, see step 2
# CLAUDE_CURRENT_NATIVE_DIR the native versions directory, see step 2
# CLAUDE_CURRENT_SYSTEM_CANDIDATES colon-separated image paths, see step 2.
# Set it empty for none.
#
# HANDING THE RESULT ON (docs/README-claude-current.md)
# A launcher that starts the path exports CLAUDE_BIN=<path>,
# CLAUDE_RESOLVED_BIN=<the same path> and CLAUDE_VERIFIED_VERSION=<version>.
# `lane-start` records `claude <version>` on the register's launch line and
# keeps CLAUDE_VERIFIED_VERSION out of the session's environment. A later
# launch that finds CLAUDE_BIN equal to CLAUDE_RESOLVED_BIN, with no
# CLAUDE_VERIFIED_VERSION, knows the value was inherited from a session and
# is not an operator's pin, and resolves again.
#
# Bash 3.2 safe (macOS): no mapfile, no associative arrays, no readlink -f,
# no GNU-only flags; `timeout`, else `gtimeout`, else a watchdog; ONE symlink
# lock for every launch, never `flock`, so what PATH holds cannot split it.
set -euo pipefail
prog="claude-current"
PACKAGE="@anthropic-ai/claude-code"
usage() {
cat <<'USAGE'
claude-current [--porcelain] [--offline]
claude-current --help
Print the absolute path of the Claude Code a launch should start: the version
npm publishes, verified with --version, updating the user-writable install
first when every installed copy is behind. Never resolved through PATH.
--porcelain four lines on stdout: path=, version=, published=, status=
(status is verified, ahead, unverified or stale)
--offline no npm read and no update: the newest installed copy, unverified
Exit 0 resolved, 1 nothing runnable, 2 refused as stale, 64 usage or a
malformed CLAUDE_CURRENT_* bound.
CLAUDE_ALLOW_STALE=1 launches a stale copy anyway. The CLAUDE_CURRENT_*
variables and the hand-off to lane-start are in docs/README-claude-current.md.
USAGE
}
say() { printf '%s: %s\n' "$prog" "$*" >&2; }
die() { printf '%s: %s\n' "$prog" "$1" >&2; exit "${2:-1}"; }
porcelain=0
offline=0
while [ $# -gt 0 ]; do
case "$1" in
--porcelain) porcelain=1 ;;
--offline) offline=1 ;;
-h | --help) usage; exit 0 ;;
*)
printf '%s: unknown argument: %s\n' "$prog" "$1" >&2
usage >&2
exit 64 ;;
esac
shift
done
# ------------------------------------------------------------------ settings
posint() {
case "${1:-}" in
'' | *[!0-9]*) return 1 ;;
esac
[ "$1" -gt 0 ]
}
NPM="${CLAUDE_CURRENT_NPM:-npm}"
NODE="${CLAUDE_CURRENT_NODE:-node}"
TIMEOUT="${CLAUDE_CURRENT_TIMEOUT:-10}"
VERSION_TIMEOUT="${CLAUDE_CURRENT_VERSION_TIMEOUT:-10}"
UPDATE_TIMEOUT="${CLAUDE_CURRENT_UPDATE_TIMEOUT:-300}"
LOCK_WAIT="${CLAUDE_CURRENT_LOCK_WAIT:-330}"
for setting in TIMEOUT VERSION_TIMEOUT UPDATE_TIMEOUT LOCK_WAIT; do
eval "setting_value=\${$setting}"
# shellcheck disable=SC2154
posint "$setting_value" ||
die "CLAUDE_CURRENT_$setting must be a whole number of seconds above 0, and it is '$setting_value'" 64
done
NATIVE_DIR="${CLAUDE_CURRENT_NATIVE_DIR:-$HOME/.local/share/claude/versions}"
CACHE_DIR="${CLAUDE_CURRENT_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openrepotools}"
# Every checkout updating this user's install must take the same lock, even
# when the override (or XDG_CACHE_HOME) is relative and their cwd differs.
case "$CACHE_DIR" in
/*) : ;;
*) CACHE_DIR="$HOME/$CACHE_DIR" ;;
esac
LOCK="$CACHE_DIR/claude-current.lock.l"
# A version is x.y.z, optionally with a pre-release or build tail.
version_re='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$'
core_re='^[0-9]+\.[0-9]+\.[0-9]+$'
is_version() { [[ "${1:-}" =~ $version_re ]]; }
# ------------------------------------------------------------ small helpers
# Seconds a command that ignores TERM is given before it is sent KILL, so a
# bound is a bound whatever the command does with the first signal.
KILL_GRACE=5
# kill_tree <pid> [<signal>]: the process and every process below it, and the
# pids it signalled, one per line on stdout. Each one is stopped before its
# children are listed, so none of them can start another while the tree is
# walked, then sent <signal> (default TERM) and continued so that it can act
# on it. `pgrep -P` is on Linux and macOS alike; without it only <pid> is
# signalled.
kill_tree() {
local child sig="${2:-TERM}"
kill -STOP "$1" 2>/dev/null || return 0
printf '%s\n' "$1"
for child in $(pgrep -P "$1" 2>/dev/null); do
kill_tree "$child" "$sig"
done
kill -"$sig" "$1" 2>/dev/null || :
kill -CONT "$1" 2>/dev/null || :
}
# running <pid>: zombies have exited, even before their parent reaps them.
# The installation lock uses this to distinguish a live owner from a dead one.
running() {
local st
kill -0 "$1" 2>/dev/null || return 1
st="$(ps -o stat= -p "$1" 2>/dev/null)" || return 0
case "$st" in *Z*) return 1 ;; esac
return 0
}
# run_bounded <seconds> <command> [args...]: the command's own status, or 124
# when it ran out of time. GNU `timeout`, else Homebrew's `gtimeout`, else a
# watchdog, because a stock macOS ships neither. Every branch sends TERM at
# the bound and KILL $KILL_GRACE seconds later to what is still running:
# `-k` for the two commands, and for the watchdog a dedicated process group.
# The watchdog ends the WHOLE tree, as `timeout` ends its process group: a
# command's own children (npm's lifecycle processes, a script's `sleep`)
# would otherwise keep the caller's command substitution open after the
# command itself was killed. The watchdog's own output goes to /dev/null for
# the same reason.
run_bounded() {
local secs="$1" pid dog rc=0 flag monitor=0
shift
if command -v timeout >/dev/null 2>&1; then
timeout -k "$KILL_GRACE" "$secs" "$@" || rc=$?
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout -k "$KILL_GRACE" "$secs" "$@" || rc=$?
else
# WHOEVER MAKES $flag FIRST DECIDES: the watchdog, when the time is up,
# or this function, when the command ended first. So this function
# never kills a watchdog half way through its TERM and KILL, and a
# watchdog that wakes after the command ended signals nothing. Where no
# flag can be made at all, the watchdog acts and this function stops it.
flag="${TMPDIR:-/tmp}/claude-current.bound.$$.$RANDOM"
# Bash job control assigns this asynchronous command its own process
# group on both Linux and stock macOS. Keep the caller's setting, and
# signal the group even after its leader exits: a TERM handler may
# create another child that still owns the output pipe.
case "$-" in *m*) monitor=1 ;; esac
set -m
"$@" &
pid=$!
[ "$monitor" = 1 ] || set +m
(
sleep "$secs"
if ! mkdir -- "$flag" 2>/dev/null && [ -d "$flag" ]; then exit 0; fi
kill -TERM -- "-$pid" 2>/dev/null || exit 0
sleep "$KILL_GRACE"
kill -KILL -- "-$pid" 2>/dev/null || :
) >/dev/null 2>&1 &
dog=$!
wait "$pid" || rc=$?
if mkdir -- "$flag" 2>/dev/null || [ ! -d "$flag" ]; then
kill_tree "$dog" >/dev/null
else
# The watchdog won even if TERM was handled as a successful exit.
rc=124
fi
wait "$dog" 2>/dev/null || :
rmdir -- "$flag" 2>/dev/null || :
[ "$rc" = 143 ] && rc=124
fi
[ "$rc" = 137 ] && rc=124
return "$rc"
}
# numcmp <a> <b>: -1, 0 or 1 for two digit strings, compared as numbers of any
# length without integer arithmetic (the same rule as workBenches #109).
numcmp() {
local x="$1" y="$2"
x="${x#"${x%%[!0]*}"}"
y="${y#"${y%%[!0]*}"}"
x="${x:-0}"
y="${y:-0}"
if [ "${#x}" -ne "${#y}" ]; then
if [ "${#x}" -gt "${#y}" ]; then echo 1; else echo -1; fi
return 0
fi
if [ "$x" = "$y" ]; then echo 0; return 0; fi
if [[ "$x" > "$y" ]]; then echo 1; else echo -1; fi
}
# precmp <a> <b>: -1, 0 or 1 for two pre-release tags, by SemVer 2.0.0 rule 11:
# dot-separated identifiers from the left; digits-only ones compare as numbers
# and sort before the others, which compare in byte order (LC_ALL=C); when
# every shared identifier is equal, the tag with fewer identifiers is lower.
# So beta.9 < beta.10, and alpha < alpha.1 < alpha.beta.
precmp() {
local a="$1" b="$2" x y xn yn c
while [ -n "$a" ] || [ -n "$b" ]; do
[ -n "$a" ] || { echo -1; return 0; }
[ -n "$b" ] || { echo 1; return 0; }
x="${a%%.*}"
y="${b%%.*}"
case "$a" in *.*) a="${a#*.}" ;; *) a="" ;; esac
case "$b" in *.*) b="${b#*.}" ;; *) b="" ;; esac
xn=1
yn=1
case "$x" in '' | *[!0-9]*) xn=0 ;; esac
case "$y" in '' | *[!0-9]*) yn=0 ;; esac
if [ "$xn$yn" = 11 ]; then
c="$(numcmp "$x" "$y")"
elif [ "$xn$yn" = 10 ]; then
c=-1
elif [ "$xn$yn" = 01 ]; then
c=1
elif [ "$x" = "$y" ]; then
c=0
elif (LC_ALL=C; [[ "$x" < "$y" ]]); then
c=-1
else
c=1
fi
[ "$c" = 0 ] || { echo "$c"; return 0; }
done
echo 0
}
# vercmp <a> <b>: -1, 0 or 1. Numeric on the x.y.z core. Then a version with no
# pre-release tag beats one with a tag, and two tags compare by precmp. Build
# metadata (+...) is ignored.
vercmp() {
local a="${1%%+*}" b="${2%%+*}" ac bc ap="" bp="" ar br i c
ac="${a%%-*}"
bc="${b%%-*}"
case "$a" in *-*) ap="${a#*-}" ;; esac
case "$b" in *-*) bp="${b#*-}" ;; esac
for i in 1 2 3; do
ar="${ac%%.*}"
br="${bc%%.*}"
c="$(numcmp "$ar" "$br")"
[ "$c" = 0 ] || { echo "$c"; return 0; }
ac="${ac#*.}"
bc="${bc#*.}"
done
if [ "$ap" = "$bp" ]; then echo 0; return 0; fi
if [ -z "$ap" ]; then echo 1; return 0; fi
if [ -z "$bp" ]; then echo -1; return 0; fi
precmp "$ap" "$bp"
}
# physical <path>: the path with every symlink resolved, without `readlink -f`,
# which BSD `readlink` did not take until macOS 12.3.
physical() {
local p="$1" link n=0 dir
while [ -L "$p" ] && [ "$n" -lt 40 ]; do
link="$(readlink -- "$p")" || break
case "$link" in
/*) p="$link" ;;
*) p="$(dirname -- "$p")/$link" ;;
esac
n=$((n + 1))
done
dir="$(cd -P -- "$(dirname -- "$p")" 2>/dev/null && pwd)" || {
printf '%s\n' "$p"
return 0
}
printf '%s/%s\n' "$dir" "$(basename -- "$p")"
}
# read_version <path>: the first version on the first line of `<path> --version`.
read_version() {
local out="" v
out="$(run_bounded "$VERSION_TIMEOUT" "$1" --version 2>/dev/null </dev/null)" || return 1
v="$(printf '%s\n' "$out" | sed -n -e '1s/^[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*[-+0-9A-Za-z.]*\).*$/\1/p')"
is_version "$v" || return 1
printf '%s\n' "$v"
}
# ----------------------------------------------------------- the candidates
# The user npm prefix: the one place an update may write.
npm_user_prefix() {
local p=""
if [ -n "${CLAUDE_CURRENT_NPM_PREFIX:-}" ]; then
p="$CLAUDE_CURRENT_NPM_PREFIX"
elif [ -n "${NPM_CONFIG_PREFIX:-}" ]; then
p="$NPM_CONFIG_PREFIX"
elif [ -n "${npm_config_prefix:-}" ]; then
p="$npm_config_prefix"
elif [ -r "$HOME/.npmrc" ]; then
p="$(sed -n -e 's/^[[:space:]]*prefix[[:space:]]*=[[:space:]]*//p' "$HOME/.npmrc" | tail -n 1)"
p="${p%"${p##*[![:space:]]}"}"
p="${p%\"}"
p="${p#\"}"
fi
case "$p" in
'~') p="$HOME" ;;
'~/'*) p="$HOME/${p#'~/'}" ;;
'${HOME}'*) p="$HOME${p#'${HOME}'}" ;;
'$HOME'*) p="$HOME${p#'$HOME'}" ;;
esac
p="${p%/}"
[ -n "$p" ] || p="$HOME/.npm-global"
# A relative prefix is anchored at $HOME, so the path npm installs into is
# the path read back, and the same one whichever directory asks.
case "$p" in
/*) ;;
*) p="$HOME/$p" ;;
esac
printf '%s\n' "$p"
}
USER_PREFIX="$(npm_user_prefix)"
cands="" # one "<path><TAB><version>" line per candidate, in order
seen=$'\n' # the physical paths already read
native_path="" # the native candidate that runs, when there is one
native_broken=$'\n' # the native files whose version read failed, until something may have changed them
add_candidate() {
local p="$1" real v
case "$p" in /*) ;; *) return 0 ;; esac
[ -f "$p" ] && [ -x "$p" ] || return 0
real="$(physical "$p")"
case "$seen" in *$'\n'"$real"$'\n'*) return 0 ;; esac
seen="$seen$real"$'\n'
v="$(read_version "$p")" || return 0
cands="$cands$p"$'\t'"$v"$'\n'
}
# native_names: the x.y.z names of the executable regular files in the native
# versions directory, highest first (workBenches #109's ordering), one per
# line. One pass and one `sort`, because ordering by `vercmp` is a process for
# every comparison.
native_names() {
local f name
for f in "$NATIVE_DIR"/*; do
[ -f "$f" ] && [ -x "$f" ] && [ ! -L "$f" ] || continue
name="${f##*/}"
[[ "$name" =~ $core_re ]] || continue
printf '%s\n' "$name"
done | sort -t. -k1,1nr -k2,2nr -k3,3nr
}
# add_native: the native candidate is the newest native file whose `--version`
# answers. A newer file that does not run (a download cut short, a binary for
# another libc, a first start that hangs) must not hide an older one that
# does, so each is read from the highest down and the first that answers is
# kept. A working newest file costs one read. A file that failed is
# remembered, because collect runs up to four times and a hung file would cost
# its whole bound on each, and forgotten wherever something else may have
# written it: after a wait for the lock, when another launch may have finished
# a file this one read half written, and after `claude update`, which rewrites
# files, the failed one among them.
add_native() {
local name f before
native_path=""
[ -d "$NATIVE_DIR" ] || return 0
while IFS= read -r name; do
[ -n "$name" ] || continue
f="$NATIVE_DIR/$name"
case "$native_broken" in *$'\n'"$f"$'\n'*) continue ;; esac
before="$cands"
add_candidate "$f"
if [ "$cands" != "$before" ]; then
native_path="$f"
return 0
fi
native_broken="$native_broken$f"$'\n'
done <<EOF
$(native_names)
EOF
}
collect() {
local p old_ifs
cands=""
seen=$'\n'
add_native
add_candidate "$USER_PREFIX/bin/claude"
add_candidate "$HOME/.local/bin/claude"
old_ifs="$IFS"
set -f
IFS=:
for p in ${CLAUDE_CURRENT_SYSTEM_CANDIDATES-/usr/local/bin/claude:/usr/bin/claude}; do
IFS="$old_ifs"
add_candidate "$p"
IFS=:
done
IFS="$old_ifs"
set +f
}
# pick <mode> [<published>]: sets CHOSEN_PATH and CHOSEN_VERSION, or returns 1.
# equal the first candidate whose version equals <published>
# ahead the newest candidate newer than <published>
# newest the newest candidate at all (the first of equal ones)
pick() {
local mode="$1" ref="${2:-}" p v c best="" bestv=""
while IFS=$'\t' read -r p v; do
[ -n "$p" ] || continue
case "$mode" in
equal)
if [ "$(vercmp "$v" "$ref")" = 0 ]; then
CHOSEN_PATH="$p"
CHOSEN_VERSION="$v"
return 0
fi
continue ;;
ahead)
[ "$(vercmp "$v" "$ref")" = 1 ] || continue ;;
esac
if [ -z "$best" ]; then
best="$p"
bestv="$v"
continue
fi
c="$(vercmp "$v" "$bestv")"
if [ "$c" = 1 ]; then
best="$p"
bestv="$v"
fi
done <<EOF
$cands
EOF
[ -n "$best" ] || return 1
CHOSEN_PATH="$best"
CHOSEN_VERSION="$bestv"
return 0
}
installed_summary() {
local p v out=""
while IFS=$'\t' read -r p v; do
[ -n "$p" ] || continue
out="${out:+$out; }$v at $p"
done <<EOF
$cands
EOF
printf '%s' "${out:-none}"
}
# ------------------------------------------------------------ the lock
#
# ONE LOCK FOR EVERY LAUNCH, WHATEVER ITS PATH HOLDS (Copilot on #134). The
# lock was `flock` where PATH had one and a symlink where it did not, so two
# launches with different PATHs held two independent locks and both updated
# the same install at once. Every launch now takes the one symlink at $LOCK,
# and `flock` is never consulted.
#
# THE SYMLINK'S TEXT NAMES ITS OWNER, `pid=<pid>@<place>`. `ln -s` makes the
# lock and publishes the owner in one atomic act, so a launch killed at any
# point leaves either no lock or a lock that names it, never an ownerless one
# every later launch would wait on. <place> is where that pid means anything:
# the host's name and, where /proc shows one, the inode of the pid namespace.
# Containers that share one home share this lock, and a pid read in another
# namespace says nothing about whether its owner still runs, which `flock`
# never needed to know. So a lock made ELSEWHERE is waited on and never taken
# over, and the refusal names it and its place; a lock made HERE whose owner
# is no longer running is taken over.
#
# ONE LAUNCH REMOVES A DEAD OWNER'S LOCK, and it holds a second directory, the
# reaper, while it looks again. Two launches that both read the same dead pid
# would otherwise both remove "the stale lock", and the second removal would
# take the lock the first had just made, so both would update at once. Under
# the reaper the lock is removed only while it still names the owner that was
# seen dead. A reaper left by a launch killed in those few lines blocks the
# takeover, and the refusal names it.
OWNER=""
PLACE=""
link_lock=""
lock_why=""
lock_slept=0 # 1 once this launch has waited for the lock
# lock_place: this launch's <place>, `<host>` or `<host>:<pid namespace inode>`.
# No `/`, because `ln -s` onto a directory names the link it puts inside after
# the target's last component.
lock_place() {
local host ns
host="$(uname -n 2>/dev/null || :)"
ns="$(readlink "/proc/$$/ns/pid" 2>/dev/null || :)"
# `pid:[4026531836]` on Linux, nothing without /proc: its digits or none.
ns="${ns//[!0-9]/}"
case "$host" in '' | *[/@:]*) host=unknown ;; esac
printf '%s' "$host${ns:+:$ns}"
}
# take_lock: 0 with the lock held, or 1 with lock_why saying why not.
take_lock() {
local waited=0 missing=0 record owner where reaped
if ! mkdir -p -- "$CACHE_DIR" 2>/dev/null; then
lock_why="the update lock's directory $CACHE_DIR could not be created"
return 1
fi
PLACE="$(lock_place)"
OWNER="pid=$$@$PLACE"
while :; do
if ln -s "$OWNER" "$LOCK" 2>/dev/null; then
[ "$(readlink -- "$LOCK" 2>/dev/null || :)" = "$OWNER" ] && break
# A directory at that path: `ln` put the link inside it.
rm -f -- "$LOCK/$OWNER" 2>/dev/null || :
lock_why="$LOCK is a directory and not this command's lock: remove it"
return 1
fi
if [ ! -L "$LOCK" ] && [ ! -e "$LOCK" ]; then
# Nothing there and still no lock: one that was released between
# the two acts is taken at the next try, and a directory this
# launch cannot write in is named now rather than waited out.
missing=$((missing + 1))
if [ "$missing" -ge 2 ]; then
lock_why="the update lock $LOCK could not be made in $CACHE_DIR"
return 1
fi
continue
fi
missing=0
record="$(readlink -- "$LOCK" 2>/dev/null || :)"
owner=""
where=""
case "$record" in
pid=*@*)
owner="${record#pid=}"
owner="${owner%%@*}"
where="${record#*@}" ;;
esac
case "$owner" in '' | *[!0-9]*) owner="" ;; esac
# A lock made here whose owner has gone, or that names this very pid
# (a dead launch's, which this one never took), is a lock nobody holds.
if [ -n "$owner" ] && [ "$where" = "$PLACE" ] &&
{ [ "$owner" = "$$" ] || ! running "$owner"; } &&
mkdir -- "$LOCK.reap" 2>/dev/null; then
reaped=0
if [ "$(readlink -- "$LOCK" 2>/dev/null || :)" = "$record" ] &&
rm -f -- "$LOCK" 2>/dev/null && [ ! -L "$LOCK" ]; then
reaped=1
fi
rmdir -- "$LOCK.reap" 2>/dev/null || :
# Taken over: try for the lock at once. Not taken over (another
# launch got there first, or the lock would not go): wait as usual.
[ "$reaped" = 1 ] && continue
fi
if [ "$waited" -ge "$LOCK_WAIT" ]; then
lock_why="the update lock $LOCK was not free within ${LOCK_WAIT}s"
if [ -n "$owner" ] && [ "$where" != "$PLACE" ]; then
lock_why="$lock_why. It is held by pid $owner at $where, and this launch runs at $PLACE, where that pid says nothing about whether its owner still runs: remove $LOCK if no launch is running there"
elif [ -z "$owner" ]; then
lock_why="$lock_why. It is not a lock this command makes (it reads '${record:-not a symlink}'): remove $LOCK if no launch is running"
fi
[ -d "$LOCK.reap" ] &&
lock_why="$lock_why, and $LOCK.reap, the directory a launch holds while it removes a dead owner's lock, is still there: remove it if no launch is running"
return 1
fi
sleep 1
waited=$((waited + 1))
lock_slept=1
done
link_lock="$LOCK"
lock_why=""
return 0
}
release_lock() {
if [ -n "$link_lock" ]; then
if [ "$(readlink -- "$link_lock" 2>/dev/null || :)" = "$OWNER" ]; then
rm -f -- "$link_lock"
fi
link_lock=""
fi
}
trap release_lock EXIT
# ------------------------------------------------------------ the update
update_why=""
update_native() {
local rc=0
say "updating the native install toward $PUBLISHED: $native_path update"
run_bounded "$UPDATE_TIMEOUT" "$native_path" update </dev/null >&2 9>&- || rc=$?
[ "$rc" = 0 ] || update_why="\`$native_path update\` exited $rc"
return "$rc"
}
update_npm() {
local rc=0 v hook
if ! command -v "$NPM" >/dev/null 2>&1; then
update_why="there is no npm ('$NPM') to install $PACKAGE@$PUBLISHED with"
return 1
fi
if ! mkdir -p -- "$USER_PREFIX" 2>/dev/null; then
update_why="the user npm prefix $USER_PREFIX could not be created"
return 1
fi
say "updating $USER_PREFIX to claude $PUBLISHED: $NPM install -g --prefix $USER_PREFIX $PACKAGE@$PUBLISHED"
run_bounded "$UPDATE_TIMEOUT" "$NPM" install -g --prefix "$USER_PREFIX" --no-fund --no-audit \
"$PACKAGE@$PUBLISHED" </dev/null >&2 9>&- || rc=$?
if [ "$rc" != 0 ]; then
update_why="\`$NPM install -g --prefix $USER_PREFIX $PACKAGE@$PUBLISHED\` exited $rc"
return "$rc"
fi
# npm 12's install-script policy skips the package's native-binary hook, so
# the launcher it links can be a stub until that hook runs. The shared image
# runs the same hook by hand (workBenches base-image/install-ai-clis.sh).
v="$(read_version "$USER_PREFIX/bin/claude" || :)"
hook="$USER_PREFIX/lib/node_modules/$PACKAGE/install.cjs"
if [ "$v" != "$PUBLISHED" ] && [ -f "$hook" ]; then
say "running the package's own install hook, which npm skipped: $NODE $hook"
rc=0
run_bounded "$UPDATE_TIMEOUT" "$NODE" "$hook" </dev/null >&2 9>&- || rc=$?
[ "$rc" = 0 ] || update_why="the package's install hook ($hook) exited $rc"
fi
return 0
}
# ------------------------------------------------------------ the answer
PUBLISHED=""
CHOSEN_PATH=""
CHOSEN_VERSION=""
answer() { # <status> <stderr line>
say "$2"
if [ "$porcelain" = 1 ]; then
printf 'path=%s\nversion=%s\npublished=%s\nstatus=%s\n' \
"$CHOSEN_PATH" "$CHOSEN_VERSION" "$PUBLISHED" "$1"
else
printf '%s\n' "$CHOSEN_PATH"
fi
exit 0
}
none_installed() { # <why>
die "no runnable Claude Code was found by absolute path ($1). Looked in: $NATIVE_DIR, $USER_PREFIX/bin/claude, $HOME/.local/bin/claude and ${CLAUDE_CURRENT_SYSTEM_CANDIDATES-/usr/local/bin/claude:/usr/bin/claude}. Install it with: npm install -g --prefix $USER_PREFIX $PACKAGE" 1
}
collect
if [ "$offline" = 1 ]; then
pick newest || none_installed "--offline reads no npm and installs nothing"
answer unverified "claude $CHOSEN_VERSION at $CHOSEN_PATH (--offline: not checked against npm)"
fi
published_why=""
if ! command -v "$NPM" >/dev/null 2>&1; then
published_why="there is no npm ('$NPM') to ask"
else
view_rc=0
view_out="$(run_bounded "$TIMEOUT" "$NPM" view "$PACKAGE" version --prefer-online 2>/dev/null </dev/null)" || view_rc=$?
if [ "$view_rc" = 124 ]; then
published_why="npm view took longer than ${TIMEOUT}s"
elif [ "$view_rc" != 0 ]; then
published_why="npm view exited $view_rc"
else
view_last="$(printf '%s\n' "$view_out" | sed -n -e '$p' | tr -d "'\"[:space:]")"
if is_version "$view_last"; then
PUBLISHED="$view_last"
else
published_why="npm view answered '$view_out'"
fi
fi
fi
if [ -z "$PUBLISHED" ]; then
pick newest || none_installed "and npm could not be read: $published_why"
answer unverified "UNVERIFIED: could not reach npm ($published_why); launching claude $CHOSEN_VERSION at $CHOSEN_PATH"
fi
if pick equal "$PUBLISHED"; then
answer verified "claude $CHOSEN_VERSION (verified against npm $PUBLISHED) at $CHOSEN_PATH"
fi
if pick ahead "$PUBLISHED"; then
answer ahead "claude $CHOSEN_VERSION (ahead of npm $PUBLISHED) at $CHOSEN_PATH"
fi
# Every candidate is behind, or there is none. Update under the lock, and read
# again after taking it: another launch may have updated while this one waited.
locked=0
if take_lock; then
locked=1
fi
# Whoever held the lock while this launch waited may have finished or repaired
# a native it read half written.
[ "$lock_slept" = 0 ] || native_broken=$'\n'
collect
if pick equal "$PUBLISHED"; then
release_lock
answer verified "claude $CHOSEN_VERSION (verified against npm $PUBLISHED) at $CHOSEN_PATH, updated by another launch"
fi
# The same two answers as before the wait, in the same order: a copy newer
# than npm that appeared while this launch waited is `ahead` here too, and not
# a reason to update again or to refuse (Copilot on #134).
if pick ahead "$PUBLISHED"; then
release_lock
answer ahead "claude $CHOSEN_VERSION (ahead of npm $PUBLISHED) at $CHOSEN_PATH, updated by another launch"
fi
if [ "$locked" = 1 ]; then
if [ -n "$native_path" ]; then
update_native || :
native_broken=$'\n' # the update rewrites files, the failed one among them
collect
fi
if ! pick equal "$PUBLISHED" && ! pick ahead "$PUBLISHED"; then
update_npm || :
collect
fi
release_lock
if pick equal "$PUBLISHED"; then
answer verified "claude $CHOSEN_VERSION (verified against npm $PUBLISHED) at $CHOSEN_PATH, updated"
fi
if pick ahead "$PUBLISHED"; then
answer ahead "claude $CHOSEN_VERSION (ahead of npm $PUBLISHED) at $CHOSEN_PATH, updated"
fi
fi
why="${lock_why:-${update_why:-the update ran and still did not produce $PUBLISHED}}"
if ! pick newest; then
none_installed "and installing $PUBLISHED did not produce one: $why"
fi
if [ "${CLAUDE_ALLOW_STALE:-}" = 1 ]; then
answer stale "STALE: claude $CHOSEN_VERSION at $CHOSEN_PATH is behind npm $PUBLISHED ($why); launching it because CLAUDE_ALLOW_STALE=1"
fi
fix="npm install -g --prefix $USER_PREFIX $PACKAGE@$PUBLISHED"
[ -n "$native_path" ] && fix="$native_path update, or $fix"
die "REFUSED: npm publishes claude $PUBLISHED and the newest installed is $CHOSEN_VERSION at $CHOSEN_PATH, and the update did not produce $PUBLISHED ($why). Installed: $(installed_summary). Fix: $fix. CLAUDE_ALLOW_STALE=1 launches $CHOSEN_VERSION anyway." 2