@workweave/router 0.1.0

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/install.sh ADDED
@@ -0,0 +1,936 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Configures Claude Code to permanently route through the Weave Router by
4
+ # writing the router base URL, router auth header, and a status line into
5
+ # Claude Code's settings.json. After running, `claude` Just Works — no shell
6
+ # exports, no manual settings edits.
7
+ #
8
+ # Two scopes:
9
+ # - user (default): ~/.claude/settings.json + ~/.weave/cc-statusline.sh
10
+ # - project: <repo>/.claude/settings.json + <repo>/.claude/cc-statusline.sh
11
+ #
12
+ # Or pass --dir to install into any directory:
13
+ # - dir: <dir>/.claude/settings.json + <dir>/.claude/cc-statusline.sh
14
+ #
15
+ # Usage:
16
+ # npx @workweave/router # hosted router, user scope
17
+ # npx @workweave/router --scope project # commit-with-team install
18
+ # npx @workweave/router --dir /tmp/my-sandbox # isolated throwaway install
19
+ # npx @workweave/router --local # local router on localhost:8080
20
+ # npx @workweave/router --base-url http://localhost:8080 # self-hosted, custom port
21
+ # npx @workweave/router --non-interactive # require WEAVE_ROUTER_KEY env var
22
+ # npx @workweave/router --quiet # suppress banner, ping check, and trailing tips
23
+ # npx @workweave/router --uninstall # remove a previous install (delegates to uninstall.sh)
24
+
25
+ set -euo pipefail
26
+
27
+ # ---------- defaults ----------
28
+
29
+ # The hosted Weave Router URL. Override with --base-url for self-hosted.
30
+ DEFAULT_BASE_URL="${WEAVE_ROUTER_URL:-https://router.workweave.ai}"
31
+
32
+
33
+ scope="user"
34
+ scope_explicit="false"
35
+ install_dir=""
36
+ base_url=""
37
+ non_interactive="false"
38
+ quiet="false"
39
+ router_key_header="X-Weave-Router-Key"
40
+
41
+ # ---------- helpers ----------
42
+
43
+ # Detect whether stdout is a real terminal that grokks ANSI escapes. Pipes,
44
+ # CI logs, and `curl ... | sh` redirects all fail this check, so we degrade
45
+ # to plain ASCII output instead of leaking raw escape bytes.
46
+ if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
47
+ tty_out="true"
48
+ else
49
+ tty_out="false"
50
+ fi
51
+
52
+ # Brand color (#FF6C47) plus a few supporting shades. Truecolor escapes work
53
+ # on every modern terminal (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
54
+ # wezterm, kitty); on TTY-less output we blank them out.
55
+ if [ "$tty_out" = "true" ]; then
56
+ C_BRAND=$'\033[38;2;255;108;71m'
57
+ C_DIM=$'\033[2m'
58
+ C_BOLD=$'\033[1m'
59
+ C_RED=$'\033[31m'
60
+ C_YELLOW=$'\033[33m'
61
+ C_GREEN=$'\033[32m'
62
+ C_CYAN=$'\033[36m'
63
+ C_RESET=$'\033[0m'
64
+ else
65
+ C_BRAND=""; C_DIM=""; C_BOLD=""; C_RED=""; C_YELLOW=""; C_GREEN=""; C_CYAN=""; C_RESET=""
66
+ fi
67
+
68
+ err() { printf "%serror:%s %s\n" "$C_RED" "$C_RESET" "$*" >&2; }
69
+ warn() { printf "%swarning:%s %s\n" "$C_YELLOW" "$C_RESET" "$*" >&2; }
70
+ info() { printf "%s==>%s %s\n" "$C_CYAN" "$C_RESET" "$*"; }
71
+ ok() { printf "%s✓%s %s\n" "$C_GREEN" "$C_RESET" "$*"; }
72
+ skip() { printf "%s⊙%s %s%s%s\n" "$C_DIM" "$C_RESET" "$C_DIM" "$*" "$C_RESET"; }
73
+
74
+ # ---------- banner ----------
75
+ #
76
+ # Print the WEAVE wordmark in brand orange. Skipped under --quiet or when
77
+ # stdout isn't a TTY so log captures don't get junk box-drawing chars.
78
+ print_banner() {
79
+ [ "$quiet" = "true" ] && return 0
80
+ [ "$tty_out" = "true" ] || return 0
81
+ printf '\n'
82
+ printf '%s ╦ ╦╔═╗╔═╗╦ ╦╔═╗%s\n' "$C_BRAND" "$C_RESET"
83
+ printf '%s ║║║║╣ ╠═╣╚╗╔╝║╣ %s\n' "$C_BRAND" "$C_RESET"
84
+ printf '%s ╚╩╝╚═╝╩ ╩ ╚╝ ╚═╝%s\n' "$C_BRAND" "$C_RESET"
85
+ printf ' %sWeave Router · Claude Code installer%s\n\n' "$C_DIM" "$C_RESET"
86
+ }
87
+
88
+ # ---------- spinner ----------
89
+ #
90
+ # Pure-bash spinner. `spin "label" cmd args...` runs cmd in the background,
91
+ # cycles dots frames in place while it runs, then replaces the line with
92
+ # ✓ or ✗ depending on exit status. Skipped (synchronous fallback) when
93
+ # stdout is not a TTY — pipes and CI logs would otherwise eat the carriage
94
+ # returns and leave a blob of frames. The command's own stdout/stderr is
95
+ # captured to $spin_log so we can echo it on failure for debugging.
96
+ #
97
+ # Frame set is `dots` from sindresorhus/cli-spinners.
98
+ SPIN_FRAMES='⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏'
99
+ SPIN_INTERVAL=0.08
100
+ spin_pid=""
101
+ spin_log=""
102
+
103
+ _spin_cleanup() {
104
+ # Kill any active spinner child and restore the cursor. Called from the
105
+ # global EXIT/INT/TERM/HUP trap so Ctrl-C never leaves a dangling spinner
106
+ # process or a hidden cursor behind.
107
+ if [ -n "$spin_pid" ] && kill -0 "$spin_pid" 2>/dev/null; then
108
+ kill "$spin_pid" 2>/dev/null || true
109
+ wait "$spin_pid" 2>/dev/null || true
110
+ fi
111
+ spin_pid=""
112
+ if [ "$tty_out" = "true" ]; then
113
+ printf '\033[?25h' # show cursor
114
+ fi
115
+ [ -n "$spin_log" ] && rm -f "$spin_log" 2>/dev/null || true
116
+ # Also restore stty echo in case we died mid-keypaste prompt. macOS
117
+ # `[ -r /dev/tty ]` returns true even when the underlying device errors
118
+ # on open (ENXIO "Device not configured") under `curl | sh` and CI, so
119
+ # we gate on stdin being an actual tty before touching it.
120
+ if [ -t 0 ]; then
121
+ stty echo 2>/dev/null || true
122
+ fi
123
+ }
124
+ trap _spin_cleanup EXIT INT TERM HUP
125
+
126
+ spin() {
127
+ local label="$1"; shift
128
+ if [ "$tty_out" != "true" ] || [ "$quiet" = "true" ]; then
129
+ # No spinner — just run the command and emit a single check line after.
130
+ if "$@" >/dev/null 2>&1; then
131
+ ok "$label"
132
+ return 0
133
+ else
134
+ local rc=$?
135
+ printf "%s✗%s %s\n" "$C_RED" "$C_RESET" "$label" >&2
136
+ return $rc
137
+ fi
138
+ fi
139
+
140
+ spin_log="$(mktemp -t weave-install.XXXXXX)"
141
+ ( "$@" >"$spin_log" 2>&1 ) &
142
+ spin_pid=$!
143
+
144
+ printf '\033[?25l' # hide cursor
145
+ local i=0
146
+ # shellcheck disable=SC2206
147
+ local frames=($SPIN_FRAMES)
148
+ local n=${#frames[@]}
149
+ while kill -0 "$spin_pid" 2>/dev/null; do
150
+ printf '\r%s%s%s %s' "$C_BRAND" "${frames[i]}" "$C_RESET" "$label"
151
+ i=$(( (i + 1) % n ))
152
+ sleep "$SPIN_INTERVAL"
153
+ done
154
+
155
+ wait "$spin_pid"
156
+ local rc=$?
157
+ spin_pid=""
158
+ printf '\033[?25h' # show cursor
159
+ printf '\r\033[2K' # clear line
160
+
161
+ if [ $rc -eq 0 ]; then
162
+ printf '%s✓%s %s\n' "$C_GREEN" "$C_RESET" "$label"
163
+ rm -f "$spin_log"
164
+ spin_log=""
165
+ return 0
166
+ else
167
+ printf '%s✗%s %s\n' "$C_RED" "$C_RESET" "$label" >&2
168
+ if [ -s "$spin_log" ]; then
169
+ printf '%s' "$C_DIM" >&2
170
+ sed 's/^/ /' "$spin_log" >&2
171
+ printf '%s' "$C_RESET" >&2
172
+ fi
173
+ rm -f "$spin_log"
174
+ spin_log=""
175
+ return $rc
176
+ fi
177
+ }
178
+
179
+ usage() {
180
+ # Print the leading comment block (lines 2..just-before `set -euo`), stripping
181
+ # the leading `# `. awk avoids GNU `head -n -<N>`, which BSD head on macOS
182
+ # rejects with "illegal line count -- -N". Banner sits above so `--help`
183
+ # gets the same wordmark as a fresh install run.
184
+ print_banner
185
+ awk 'NR<2 { next } /^set -euo/ { exit } { sub(/^# ?/, ""); print }' "$0"
186
+ exit "${1:-0}"
187
+ }
188
+
189
+ require_cmd() {
190
+ local cmd="$1" hint="$2"
191
+ if ! command -v "$cmd" >/dev/null 2>&1; then
192
+ err "$cmd is required but not installed."
193
+ printf " install: %s\n" "$hint" >&2
194
+ exit 1
195
+ fi
196
+ }
197
+
198
+ # Refuse to write through a symlink. Project scope reads the install path from
199
+ # the user's git repo; a malicious checkout could ship `.claude/settings.json`
200
+ # (or `.claude/` itself) as a symlink to e.g. `~/.ssh/authorized_keys`, and
201
+ # the installer's mkdir/chmod/cp/jq>file would silently follow that link.
202
+ refuse_if_symlink() {
203
+ local target="$1"
204
+ if [ -L "$target" ]; then
205
+ err "$target is a symlink (-> $(readlink "$target")). Refusing to write through it."
206
+ exit 1
207
+ fi
208
+ }
209
+
210
+ # ---------- uninstall delegation ----------
211
+ #
212
+ # `--uninstall` flips this script into a thin shim for uninstall.sh: the
213
+ # canonical uninstall logic lives in a sibling file, and we want both
214
+ # direct invocations (`./install.sh --uninstall`) and curl-piped ones
215
+ # (`curl ... | sh -s -- --uninstall`) to behave the same as
216
+ # `npx @workweave/router --uninstall` (which bin.js routes to uninstall.sh on
217
+ # its own).
218
+ #
219
+ # Scan every arg, not just $1, so flag order doesn't matter; build a clean
220
+ # list with --uninstall stripped and exec uninstall.sh with the remainder.
221
+ #
222
+ # Resolution order for the uninstall script:
223
+ # 1. Sibling file next to install.sh on disk (npm tarball / git checkout).
224
+ # 2. WEAVE_UNINSTALL_URL override (self-hosters who fork).
225
+ # 3. Default: raw.githubusercontent.com canonical copy (curl|sh path).
226
+ for arg in "$@"; do
227
+ if [ "$arg" = "--uninstall" ]; then
228
+ cleaned_args=()
229
+ for a in "$@"; do
230
+ [ "$a" = "--uninstall" ] || cleaned_args+=("$a")
231
+ done
232
+
233
+ script_path="${BASH_SOURCE[0]:-$0}"
234
+ if [ -f "$script_path" ]; then
235
+ sibling_dir="$(cd "$(dirname "$script_path")" 2>/dev/null && pwd)"
236
+ if [ -n "$sibling_dir" ] && [ -f "$sibling_dir/uninstall.sh" ]; then
237
+ exec bash "$sibling_dir/uninstall.sh" "${cleaned_args[@]+"${cleaned_args[@]}"}"
238
+ fi
239
+ fi
240
+
241
+ require_cmd curl "https://curl.se"
242
+ url="${WEAVE_UNINSTALL_URL:-https://raw.githubusercontent.com/workweave/router/main/install/uninstall.sh}"
243
+ # Pull the body into memory and exec via `bash -c` so we never touch
244
+ # disk: `exec` replaces this process, so any temp file we wrote would
245
+ # outlive the EXIT trap and leak indefinitely. Loading into a variable
246
+ # also gives us a chance to fail closed on 404 HTML pages before
247
+ # handing the content to bash.
248
+ if ! uninstall_body="$(curl -fsSL --max-time 30 "$url" 2>/dev/null)"; then
249
+ err "failed to fetch uninstall.sh from $url"
250
+ exit 1
251
+ fi
252
+ if [ -z "$uninstall_body" ] || [ "${uninstall_body:0:2}" != "#!" ]; then
253
+ err "fetched content from $url doesn't look like a bash script"
254
+ exit 1
255
+ fi
256
+ exec bash -c "$uninstall_body" weave-uninstall "${cleaned_args[@]+"${cleaned_args[@]}"}"
257
+ fi
258
+ done
259
+
260
+ # ---------- arg parsing ----------
261
+
262
+ while [ $# -gt 0 ]; do
263
+ case "$1" in
264
+ --scope)
265
+ scope="${2:-}"; shift 2
266
+ [ "$scope" = "user" ] || [ "$scope" = "project" ] || { err "--scope must be 'user' or 'project'"; exit 2; }
267
+ scope_explicit="true"
268
+ ;;
269
+ --base-url)
270
+ base_url="${2:-}"; shift 2
271
+ [ -n "$base_url" ] || { err "--base-url requires a value"; exit 2; }
272
+ ;;
273
+ --local)
274
+ # Shorthand for local dev: localhost:8080 (matches `wv mr` / `make dev` default PORT).
275
+ base_url="http://localhost:8080"
276
+ shift
277
+ ;;
278
+ --non-interactive)
279
+ non_interactive="true"; shift
280
+ ;;
281
+ --quiet)
282
+ quiet="true"; shift
283
+ ;;
284
+ --dir)
285
+ install_dir="${2:-}"; shift 2
286
+ [ -n "$install_dir" ] || { err "--dir requires a path"; exit 2; }
287
+ ;;
288
+ -h|--help)
289
+ usage 0
290
+ ;;
291
+ *)
292
+ err "unknown flag: $1"; usage 2
293
+ ;;
294
+ esac
295
+ done
296
+
297
+ if [ -z "$base_url" ]; then
298
+ base_url="$DEFAULT_BASE_URL"
299
+ fi
300
+ # trim trailing slash for cleanliness
301
+ base_url="${base_url%/}"
302
+
303
+ # Banner runs before the interactive scope prompt so the very first thing
304
+ # users see when `make full-setup` hands off to install.sh is the wordmark,
305
+ # not a bare "Install scope:" line.
306
+ print_banner
307
+
308
+ # ---------- interactive scope prompt ----------
309
+
310
+ # If the user didn't pass --scope and we have a controlling terminal, ask which
311
+ # scope to install into. Non-interactive runs (CI, `curl | sh --non-interactive`)
312
+ # silently use the "user" default.
313
+ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r /dev/tty ]; then
314
+ printf "%sInstall scope:%s\n" "$C_BOLD" "$C_RESET"
315
+ printf " %s1)%s user %s— write to ~/.claude/ (applies everywhere you run claude)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
316
+ printf " %s2)%s project %s— write to <repo>/.claude/ (applies only inside this repo)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
317
+ printf "Choose %s[1/2]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
318
+ read -r scope_choice </dev/tty || scope_choice=""
319
+ case "${scope_choice:-1}" in
320
+ 1|""|user|u|U) scope="user" ;;
321
+ 2|project|p|P) scope="project" ;;
322
+ *) err "invalid choice: $scope_choice"; exit 2 ;;
323
+ esac
324
+
325
+ # For project scope, ask which directory rather than silently assuming CWD.
326
+ # A user running this from a shell that happens to be in $HOME or some
327
+ # unrelated repo would otherwise scribble .claude/ into the wrong place.
328
+ if [ "$scope" = "project" ]; then
329
+ default_project_dir="$(pwd)"
330
+ printf "Project directory [default: %s]: " "$default_project_dir"
331
+ read -r project_dir_choice </dev/tty || project_dir_choice=""
332
+ project_dir="${project_dir_choice:-$default_project_dir}"
333
+ # Expand a leading ~ since `read` doesn't.
334
+ case "$project_dir" in
335
+ "~") project_dir="$HOME" ;;
336
+ "~/"*) project_dir="$HOME/${project_dir#~/}" ;;
337
+ esac
338
+ if [ ! -d "$project_dir" ]; then
339
+ err "directory does not exist: $project_dir"
340
+ exit 1
341
+ fi
342
+ project_dir="$(cd "$project_dir" && pwd)"
343
+ fi
344
+ fi
345
+
346
+ # ---------- pre-flight ----------
347
+
348
+ [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
349
+
350
+ require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
351
+ require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
352
+
353
+ if ! command -v claude >/dev/null 2>&1; then
354
+ warn "'claude' not found on PATH. Install Claude Code from https://claude.com/code, then re-run this script."
355
+ warn "Continuing — settings.json will be written and will take effect once Claude Code is installed."
356
+ fi
357
+
358
+ script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
359
+
360
+ # Resolve the base directory. User scope always uses $HOME. Project scope uses
361
+ # --dir if given, otherwise the CWD's git root. --dir alone (no --scope) is a
362
+ # throwaway user-style install.
363
+ if [ -n "$install_dir" ]; then
364
+ install_dir="$(cd "$install_dir" 2>/dev/null && pwd || echo "$install_dir")"
365
+ settings_base="$install_dir"
366
+ else
367
+ case "$scope" in
368
+ user)
369
+ settings_base="$HOME"
370
+ ;;
371
+ project)
372
+ # If the interactive prompt collected a project directory, use it.
373
+ # Otherwise fall back to the git root of CWD (the original behavior,
374
+ # preserved for --scope project passed on the command line).
375
+ if [ -n "${project_dir:-}" ]; then
376
+ settings_base="$project_dir"
377
+ git_root="$(cd "$project_dir" && git rev-parse --show-toplevel 2>/dev/null || true)"
378
+ else
379
+ if ! git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
380
+ err "--scope project must be run inside a git repo, or pass --dir <path>. cd into your project first, or use --dir."
381
+ exit 1
382
+ fi
383
+ settings_base="$git_root"
384
+ fi
385
+ ;;
386
+ esac
387
+ fi
388
+
389
+ case "$scope" in
390
+ user)
391
+ settings_dir="$settings_base/.claude"
392
+ settings_file="$settings_dir/settings.json"
393
+ local_settings_file=""
394
+ statusline_dir="${settings_base}/.weave"
395
+ statusline_file="$statusline_dir/cc-statusline.sh"
396
+ statusline_path_for_settings="$statusline_file"
397
+ ;;
398
+ project)
399
+ settings_dir="$settings_base/.claude"
400
+ settings_file="$settings_dir/settings.json"
401
+ local_settings_file="$settings_dir/settings.local.json"
402
+ statusline_dir="$settings_base/.claude"
403
+ statusline_file="$statusline_dir/cc-statusline.sh"
404
+ # Portable relative path for real repos (teammates can clone anywhere).
405
+ # Absolute path when --dir overrides (no meaningful $CLAUDE_PROJECT_DIR).
406
+ if [ -z "$install_dir" ]; then
407
+ statusline_path_for_settings="\${CLAUDE_PROJECT_DIR}/.claude/cc-statusline.sh"
408
+ else
409
+ statusline_path_for_settings="$statusline_file"
410
+ fi
411
+ ;;
412
+ esac
413
+
414
+ # Symlink containment: refuse if any target path is a symlink. User-scope paths
415
+ # under $HOME are trusted; project-scope and --dir paths come from a git repo or
416
+ # user-supplied directory that may be hostile, so we check those.
417
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
418
+ refuse_if_symlink "$settings_dir"
419
+ refuse_if_symlink "$settings_file"
420
+ refuse_if_symlink "$local_settings_file"
421
+ refuse_if_symlink "$statusline_file"
422
+ fi
423
+
424
+ mkdir -p "$settings_dir" "$statusline_dir"
425
+
426
+ # ---------- token handling ----------
427
+
428
+ api_key=""
429
+ if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
430
+ api_key="$WEAVE_ROUTER_KEY"
431
+ info "Using WEAVE_ROUTER_KEY from environment."
432
+ elif [ "$non_interactive" = "true" ]; then
433
+ err "--non-interactive set but WEAVE_ROUTER_KEY is empty. Export it and re-run."
434
+ exit 1
435
+ else
436
+ # Read from /dev/tty explicitly so the prompt works under `curl -fsSL ... | sh`,
437
+ # where stdin is the curl pipe (already at EOF by the time we get here, and
438
+ # `set -e` would abort on read returning 1). If /dev/tty isn't available
439
+ # (e.g. CI without a controlling terminal) the user must use --non-interactive.
440
+ if [ ! -r /dev/tty ]; then
441
+ err "no controlling terminal — set WEAVE_ROUTER_KEY and re-run with --non-interactive."
442
+ exit 1
443
+ fi
444
+ # _spin_cleanup (installed globally above) already restores stty echo on
445
+ # any exit path, so we don't need a separate trap here — that would
446
+ # overwrite the spinner cleanup and leak the cursor / child PID on Ctrl-C.
447
+ printf "%sPaste your Weave Router API key (rk_...):%s " "$C_DIM" "$C_RESET"
448
+ stty -echo </dev/tty 2>/dev/null || true
449
+ read -r api_key </dev/tty
450
+ stty echo </dev/tty 2>/dev/null || true
451
+ printf "\n"
452
+ [ -n "$api_key" ] || { err "no key provided"; exit 1; }
453
+ fi
454
+
455
+ # ---------- write the statusline script ----------
456
+
457
+ cat > "$statusline_file" << 'STATUSLINE_EOF'
458
+ #!/usr/bin/env bash
459
+ #
460
+ # Claude Code statusline for the Weave router. CC pipes a JSON blob on stdin
461
+ # whose `transcript_path` points at the JSONL log of the current session and
462
+ # whose `model.display_name` is the user's CC-side model selection. The
463
+ # router rewrites each request's `model` field before forwarding, so
464
+ # Anthropic/OpenAI/Google return `message.model = <routed>` in the SSE
465
+ # stream and CC stores that in the transcript verbatim. Per-turn savings
466
+ # come from comparing each turn's routed cost against what the user's
467
+ # selection would have cost on the same tokens. Works identically for
468
+ # local docker and the managed cloud router — no sidecar, no DB, no auth.
469
+ #
470
+ # Wire up by adding to ~/.claude/settings.json:
471
+ # { "statusLine": { "type": "command", "command": "/abs/path/to/cc-statusline.sh" } }
472
+ #
473
+ # Renders:
474
+ # WEAVE ROUTER — claude-sonnet-4-5 ← claude-opus-4-7 · saved $1.23 · 12.4k in / 3.1k out / 45.2k cached
475
+ #
476
+ # Pricing source of truth: router/eval/pricing.py. Keep these maps in lockstep
477
+ # when prices change. Cache multipliers (1.25× / 0.1×) follow Anthropic's
478
+ # published cache pricing and are stable across the Claude family.
479
+
480
+ set -euo pipefail
481
+
482
+ # ---------- background self-refresh ----------
483
+ #
484
+ # Once every WEAVE_STATUSLINE_UPDATE_INTERVAL_DAYS (default 7), check
485
+ # raw.githubusercontent.com for a newer copy of this script and swap it in
486
+ # atomically. Runs in a forked subshell so the current Claude turn never
487
+ # blocks; the next turn picks up the new version. Applies to both user-scope
488
+ # (~/.weave/cc-statusline.sh) and project-scope (<repo>/.claude/cc-statusline.sh)
489
+ # installs — project teammates rate-limit independently because the stamp
490
+ # lives in their per-user cache dir, and on no-content-change days we skip
491
+ # the mv entirely so the repo working tree stays clean. When upstream does
492
+ # change, the first teammate's commit propagates the new version to the rest.
493
+ #
494
+ # Opt out entirely with `export WEAVE_STATUSLINE_UPDATE=0`. Override the
495
+ # source with `WEAVE_STATUSLINE_URL=...`, e.g. for self-hosters who fork.
496
+ weave_self_refresh() {
497
+ [ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
498
+ command -v curl >/dev/null 2>&1 || return 0
499
+
500
+ local self="${BASH_SOURCE[0]:-$0}"
501
+ [ -f "$self" ] && [ -w "$self" ] || return 0
502
+
503
+ local interval_days="${WEAVE_STATUSLINE_UPDATE_INTERVAL_DAYS:-7}"
504
+ local interval_seconds=$(( interval_days * 86400 ))
505
+
506
+ # Stamp lives in the per-user cache dir, keyed by absolute script path so
507
+ # multiple repos (and the user-scope copy) rate-limit independently and no
508
+ # stray file ever lands inside a repo working tree.
509
+ local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router"
510
+ mkdir -p "$cache_dir" 2>/dev/null || return 0
511
+ local script_slug
512
+ script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
513
+ local stamp="$cache_dir/checked-at${script_slug}"
514
+
515
+ local now stamp_mtime
516
+ now="$(date +%s 2>/dev/null)" || return 0
517
+ if [ -f "$stamp" ]; then
518
+ # Try GNU `stat -c %Y` first; on macOS (BSD stat) -c isn't recognized
519
+ # and exits non-zero, so we fall through to `stat -f %m`. The reverse
520
+ # order is broken: GNU `stat -f` is `--file-system`, which silently
521
+ # succeeds with multi-line filesystem info instead of failing, leaving
522
+ # $stamp_mtime as garbage and disabling the rate-limit check entirely.
523
+ stamp_mtime="$(stat -c %Y "$stamp" 2>/dev/null || stat -f %m "$stamp" 2>/dev/null)" || stamp_mtime=0
524
+ else
525
+ stamp_mtime=0
526
+ fi
527
+ if [ -n "${stamp_mtime:-}" ] && [ "$stamp_mtime" -gt 0 ] \
528
+ && [ $(( now - stamp_mtime )) -lt "$interval_seconds" ]; then
529
+ return 0
530
+ fi
531
+
532
+ # Touch the stamp BEFORE forking so concurrent statusline invocations
533
+ # (Claude calls us on every turn) don't all kick off downloads.
534
+ : > "$stamp" 2>/dev/null || return 0
535
+
536
+ local url="${WEAVE_STATUSLINE_URL:-https://raw.githubusercontent.com/workweave/router/main/install/cc-statusline.sh}"
537
+ local tmp="${self}.tmp.$$"
538
+ (
539
+ # Detach stdin (CC pipes JSON to us) so curl can't accidentally consume
540
+ # it, and silence all output so nothing leaks into the statusline.
541
+ exec </dev/null
542
+ if curl -fsSL --max-time 15 "$url" -o "$tmp" 2>/dev/null \
543
+ && [ -s "$tmp" ] \
544
+ && head -n 1 "$tmp" | grep -q '^#!.*bash' \
545
+ && [ "$(wc -c < "$tmp")" -ge 1024 ]; then
546
+ # No-op when the download matches what's already on disk — keeps git
547
+ # status clean for project-scope teammates during a routine refresh.
548
+ if cmp -s "$tmp" "$self"; then
549
+ rm -f "$tmp"
550
+ else
551
+ chmod +x "$tmp" 2>/dev/null || true
552
+ mv "$tmp" "$self" 2>/dev/null || rm -f "$tmp"
553
+ fi
554
+ else
555
+ rm -f "$tmp"
556
+ fi
557
+ ) >/dev/null 2>&1 &
558
+ disown 2>/dev/null || true
559
+ return 0
560
+ }
561
+ weave_self_refresh 2>/dev/null || true
562
+
563
+ input="$(cat)"
564
+ transcript_path="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
565
+ # Prefer model.id over display_name: pricing keys + the routed model id in
566
+ # the transcript are canonical ids (e.g. claude-opus-4-7), while display_name
567
+ # is a human label ("Opus 4.7 (1M context)") that won't hit the pricing table,
568
+ # zeroing out savings. id passes through normalize_model cleanly.
569
+ selected_display="$(printf '%s' "$input" | jq -r '.model.id // .model.display_name // "?"')"
570
+
571
+ # Normalize a model id to a pricing-table key. CC + the decisions log carry
572
+ # two flavors of annotation we don't want in the lookup:
573
+ # * date suffix: claude-opus-4-7-20260101 → claude-opus-4-7
574
+ # * variant tag: claude-opus-4-7[1m] → claude-opus-4-7
575
+ # The 1M-context variant prices ~2× base for prompts >200k tokens, but for
576
+ # the "saved $X vs your selection" UX the base rate is the right comparison
577
+ # — we're measuring the model swap, not the context tier. Used below on the
578
+ # routed and requested model ids from the decisions log / transcript.
579
+ normalize_model() {
580
+ printf '%s' "$1" | sed -E 's/\[[^]]*\]$//; s/-[0-9]{8}$//'
581
+ }
582
+
583
+ # USD per 1k tokens. Generated from internal/observability/otel/pricing.go
584
+ # (USD/1M there, ÷1000 here) by cmd/genprices. Do not hand-edit — run
585
+ # `make generate` after updating pricing.go.
586
+ # BEGIN_GENERATED_PRICES
587
+ prices='{
588
+ "input": {
589
+ "claude-haiku-4-5": 0.0008,
590
+ "claude-opus-4-7": 0.015,
591
+ "claude-sonnet-4-5": 0.003,
592
+ "deepseek/deepseek-v4-flash": 0.00014,
593
+ "deepseek/deepseek-v4-pro": 0.000435,
594
+ "gemini-2.0-flash": 0.0001,
595
+ "gemini-2.0-flash-lite": 0.000075,
596
+ "gemini-2.5-flash": 0.0003,
597
+ "gemini-2.5-flash-lite": 0.0001,
598
+ "gemini-2.5-pro": 0.00125,
599
+ "gemini-3-flash-preview": 0.0005,
600
+ "gemini-3-pro-preview": 0.002,
601
+ "gemini-3.1-flash-lite-preview": 0.0001,
602
+ "gemini-3.1-pro-preview": 0.002,
603
+ "gpt-4.1": 0.002,
604
+ "gpt-4.1-mini": 0.0004,
605
+ "gpt-4.1-nano": 0.0001,
606
+ "gpt-4o": 0.0025,
607
+ "gpt-4o-mini": 0.00015,
608
+ "gpt-5": 0.0025,
609
+ "gpt-5-chat": 0.0025,
610
+ "gpt-5-mini": 0.0005,
611
+ "gpt-5-nano": 0.0001,
612
+ "gpt-5.4": 0.003,
613
+ "gpt-5.4-mini": 0.0004,
614
+ "gpt-5.4-nano": 0.0001,
615
+ "gpt-5.4-pro": 0.02,
616
+ "gpt-5.5": 0.005,
617
+ "gpt-5.5-mini": 0.0005,
618
+ "gpt-5.5-nano": 0.00015,
619
+ "gpt-5.5-pro": 0.03,
620
+ "mistralai/mistral-small-2603": 0.00015,
621
+ "moonshotai/kimi-k2.5": 0.00044,
622
+ "qwen/qwen3-235b-a22b-2507": 0.000071,
623
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.00008,
624
+ "qwen/qwen3-coder": 0.00022,
625
+ "qwen/qwen3-coder-next": 0.00007,
626
+ "qwen/qwen3-next-80b-a3b-instruct": 0.00009,
627
+ "qwen/qwen3.5-flash-02-23": 0.000065
628
+ },
629
+ "output": {
630
+ "claude-haiku-4-5": 0.004,
631
+ "claude-opus-4-7": 0.075,
632
+ "claude-sonnet-4-5": 0.015,
633
+ "deepseek/deepseek-v4-flash": 0.00028,
634
+ "deepseek/deepseek-v4-pro": 0.00087,
635
+ "gemini-2.0-flash": 0.0004,
636
+ "gemini-2.0-flash-lite": 0.0003,
637
+ "gemini-2.5-flash": 0.0012,
638
+ "gemini-2.5-flash-lite": 0.0004,
639
+ "gemini-2.5-pro": 0.005,
640
+ "gemini-3-flash-preview": 0.002,
641
+ "gemini-3-pro-preview": 0.008,
642
+ "gemini-3.1-flash-lite-preview": 0.0004,
643
+ "gemini-3.1-pro-preview": 0.008,
644
+ "gpt-4.1": 0.008,
645
+ "gpt-4.1-mini": 0.0016,
646
+ "gpt-4.1-nano": 0.0004,
647
+ "gpt-4o": 0.01,
648
+ "gpt-4o-mini": 0.0006,
649
+ "gpt-5": 0.01,
650
+ "gpt-5-chat": 0.01,
651
+ "gpt-5-mini": 0.002,
652
+ "gpt-5-nano": 0.0004,
653
+ "gpt-5.4": 0.012,
654
+ "gpt-5.4-mini": 0.0016,
655
+ "gpt-5.4-nano": 0.0004,
656
+ "gpt-5.4-pro": 0.08,
657
+ "gpt-5.5": 0.04,
658
+ "gpt-5.5-mini": 0.0025,
659
+ "gpt-5.5-nano": 0.0006,
660
+ "gpt-5.5-pro": 0.12,
661
+ "mistralai/mistral-small-2603": 0.0006,
662
+ "moonshotai/kimi-k2.5": 0.002,
663
+ "qwen/qwen3-235b-a22b-2507": 0.000463,
664
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.00033,
665
+ "qwen/qwen3-coder": 0.0018,
666
+ "qwen/qwen3-coder-next": 0.0003,
667
+ "qwen/qwen3-next-80b-a3b-instruct": 0.0011,
668
+ "qwen/qwen3.5-flash-02-23": 0.00026
669
+ }
670
+ }'
671
+ # END_GENERATED_PRICES
672
+
673
+ routed=""
674
+ session_savings=""
675
+ tot_in=0
676
+ tot_out=0
677
+ tot_cache_read=0
678
+ tot_cache_write=0
679
+
680
+ # Per-turn savings compare each turn's routed cost (priced from
681
+ # message.model in the transcript) against what the CC-side model selection
682
+ # (selected_display) would have cost on the same tokens. The selection
683
+ # isn't strictly the per-turn "requested" model — CC tags some background
684
+ # side-calls (compaction probes, title-gen) with a different model id —
685
+ # but for those the planner short-circuits to a hard pin and the savings
686
+ # math zeroes out anyway. Turns where routed == selection or where either
687
+ # model isn't in the pricing table emit 0 savings; the tokens clause
688
+ # always renders.
689
+
690
+ # Normalize the CC-side selection once for use in the jq math below.
691
+ requested_norm="$(normalize_model "$selected_display")"
692
+
693
+ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
694
+ # macOS ships `tail -r`, GNU coreutils ships `tac`. Either works to walk the
695
+ # JSONL in reverse so we can grab the latest assistant turn.
696
+ if command -v tac >/dev/null 2>&1; then reverse=(tac); else reverse=(tail -r); fi
697
+
698
+ # CC stamps message.model = "<synthetic>" on assistant turns it generated
699
+ # locally (errored requests, cancellations, tool-only stubs) instead of a
700
+ # real model id. Show that as "failure" rather than leaking the internal
701
+ # sentinel into the statusline.
702
+ routed="$("${reverse[@]}" "$transcript_path" 2>/dev/null \
703
+ | jq -r 'select(.type=="assistant") | .message.model // empty' \
704
+ | head -n 1 || true)"
705
+ if [[ "$routed" == "<synthetic>" ]]; then
706
+ routed="failure"
707
+ else
708
+ routed="$(normalize_model "$routed")"
709
+ fi
710
+
711
+ # Compute a session running total: savings across every assistant turn
712
+ # whose marker reports a requested ≠ routed swap, plus cumulative token
713
+ # counts across every assistant turn (rerouted or not — total work the
714
+ # session has done). cache_creation is priced at 1.25× input, cache_read
715
+ # at 0.1× — both ratios are stable across the Claude family and a no-op
716
+ # when the provider doesn't return those fields. Cache reads ARE included
717
+ # in the savings comparison: both costs apply the same 0.1× weight to
718
+ # cache_read_input_tokens, so the delta reflects the model-price
719
+ # difference on the cached portion as well.
720
+ #
721
+ # The marker regex tolerates the optional "(<provider>)" segment and a
722
+ # `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
723
+ # against context-tiered or dated model ids still parse cleanly.
724
+ read -r session_savings tot_in tot_out tot_cache_read tot_cache_write < <(
725
+ jq -r --argjson p "$prices" --arg requested "$requested_norm" '
726
+ select(.type=="assistant") |
727
+ .message as $m |
728
+ ($m.model // "" | sub("\\[[^]]*\\]$"; "") | sub("-[0-9]{8}$"; "")) as $rm |
729
+ {
730
+ in: ($m.usage.input_tokens // 0),
731
+ out: ($m.usage.output_tokens // 0),
732
+ cwrt: ($m.usage.cache_creation_input_tokens // 0),
733
+ crd: ($m.usage.cache_read_input_tokens // 0)
734
+ } as $t |
735
+ (if $requested == "" or $requested == $rm then 0
736
+ else
737
+ ($p.input[$rm] // null) as $rin | ($p.output[$rm] // null) as $rout |
738
+ ($p.input[$requested] // null) as $sin | ($p.output[$requested] // null) as $sout |
739
+ if ($rin == null or $rout == null or $sin == null or $sout == null) then 0
740
+ else
741
+ (($t.in + 1.25 * $t.cwrt + 0.1 * $t.crd) / 1000) as $input_units |
742
+ ($t.out / 1000) as $output_units |
743
+ ($input_units * $rin + $output_units * $rout) as $routed_cost |
744
+ ($input_units * $sin + $output_units * $sout) as $requested_cost |
745
+ ($requested_cost - $routed_cost)
746
+ end
747
+ end) as $savings |
748
+ "\($savings) \($t.in) \($t.out) \($t.crd) \($t.cwrt)"
749
+ ' "$transcript_path" 2>/dev/null \
750
+ | awk 'BEGIN{s=0; i=0; o=0; r=0; w=0}
751
+ {s+=$1; i+=$2; o+=$3; r+=$4; w+=$5}
752
+ END{printf "%.4f %d %d %d %d\n", s, i, o, r, w}'
753
+ ) || true
754
+ fi
755
+
756
+ # Brand color (#FF6C47) on terminals that grok 24-bit truecolor — that's
757
+ # every modern one (iTerm2, Apple Terminal, vscode, ghostty, alacritty,
758
+ # wezterm, kitty). Falls back gracefully on any escape-stripping terminal.
759
+ brand=$'\033[38;2;255;108;71mWEAVE ROUTER\033[0m'
760
+
761
+ # Format helpers.
762
+ fmt_money() {
763
+ awk -v v="$1" 'BEGIN{
764
+ if (v == "" || v+0 == 0) { printf "$0.00"; exit }
765
+ if (v+0 < 0.005 && v+0 > -0.005){ printf "<$0.01"; exit }
766
+ if (v+0 < 0) { printf "-$%.2f", -v+0; exit }
767
+ printf "$%.2f", v
768
+ }'
769
+ }
770
+
771
+ fmt_tok() {
772
+ awk -v v="$1" 'BEGIN{
773
+ v = v+0
774
+ if (v >= 1000000) { printf "%.1fM", v/1000000; exit }
775
+ if (v >= 1000) { printf "%.1fk", v/1000; exit }
776
+ printf "%d", v
777
+ }'
778
+ }
779
+
780
+ # cache_read tokens are the cached portion of every prompt that the
781
+ # provider serves at 0.1× input price; cache_write tokens are the bytes
782
+ # that get newly cached on this turn at 1.25× input price. They behave
783
+ # completely differently both in cost and in what they tell the user
784
+ # about session-level efficiency, so we surface them separately rather
785
+ # than summing into a single "cached" number that conflates the two.
786
+ # Each clause is shown only when nonzero, so quiet sessions stay quiet.
787
+ tokens_clause=""
788
+ if [[ "$tot_in" -gt 0 || "$tot_out" -gt 0 || "$tot_cache_read" -gt 0 || "$tot_cache_write" -gt 0 ]]; then
789
+ tokens_clause=" · $(fmt_tok "$tot_in") in / $(fmt_tok "$tot_out") out"
790
+ if [[ "$tot_cache_read" -gt 0 ]]; then
791
+ tokens_clause+=" / $(fmt_tok "$tot_cache_read") cache read"
792
+ fi
793
+ if [[ "$tot_cache_write" -gt 0 ]]; then
794
+ tokens_clause+=" / $(fmt_tok "$tot_cache_write") cache write"
795
+ fi
796
+ fi
797
+
798
+ if [[ "$routed" == "failure" ]]; then
799
+ # Latest turn was a CC-synthesized error stub — don't claim a routing
800
+ # swap or compute savings against a non-model.
801
+ printf '%s — %s%s' "$brand" "$routed" "$tokens_clause"
802
+ elif [[ -n "$routed" ]]; then
803
+ # Show the savings clause only when the session is genuinely net-saving.
804
+ # session_savings is "0.0000" on fresh sessions or sessions where every
805
+ # turn routed back to the selected model; it can also go negative when
806
+ # sticky routing forces a haiku-tagged side-call up to a cached
807
+ # sonnet/opus decision. In both cases the word "saved" would mislead,
808
+ # so drop the savings clause but keep the token totals.
809
+ has_savings="false"
810
+ if [[ -n "$session_savings" ]] \
811
+ && awk -v v="$session_savings" 'BEGIN{exit !(v+0 > 0.005)}'; then
812
+ has_savings="true"
813
+ fi
814
+ if [[ "$has_savings" == "true" ]]; then
815
+ printf '%s — %s ← %s · saved %s%s' \
816
+ "$brand" "$routed" "$selected_display" "$(fmt_money "$session_savings")" "$tokens_clause"
817
+ else
818
+ printf '%s — %s%s' "$brand" "$routed" "$tokens_clause"
819
+ fi
820
+ else
821
+ printf '%s — %s%s' "$brand" "$selected_display" "$tokens_clause"
822
+ fi
823
+ STATUSLINE_EOF
824
+ chmod +x "$statusline_file"
825
+ ok "Statusline installed at $statusline_file"
826
+
827
+ # ---------- patch settings.json ----------
828
+
829
+ # Build the merge patch. Claude Code keeps its own Anthropic auth in
830
+ # Authorization/x-api-key; the router key rides in ANTHROPIC_CUSTOM_HEADERS.
831
+ # Project scope (no --dir) writes the key to settings.local.json (gitignored)
832
+ # so teammates can share settings.json. --dir and user scope inline the key
833
+ # directly into settings.json since there's no team to coordinate with.
834
+ tmp_patch="$(mktemp)"
835
+ # Compose with the spinner cleanup trap installed above — replacing it would
836
+ # leave the cursor hidden if Ctrl-C lands during settings.json patching.
837
+ trap '_spin_cleanup; rm -f "$tmp_patch"' EXIT INT TERM HUP
838
+
839
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
840
+ jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
841
+ env: { ANTHROPIC_BASE_URL: $url },
842
+ statusLine: { type: "command", command: $sl }
843
+ }' >"$tmp_patch"
844
+ else
845
+ jq -n --arg url "$base_url" --arg header "$router_key_header: $api_key" --arg sl "$statusline_path_for_settings" '{
846
+ env: { ANTHROPIC_BASE_URL: $url, ANTHROPIC_CUSTOM_HEADERS: $header },
847
+ statusLine: { type: "command", command: $sl }
848
+ }' >"$tmp_patch"
849
+ fi
850
+
851
+ # Merge with existing settings. Deep-merge env and replace statusLine.
852
+ # We strip router-owned auth from the existing settings BEFORE merging —
853
+ # otherwise switching auth mode (key→dev-mode) would leave stale credentials
854
+ # behind. ANTHROPIC_AUTH_TOKEN/apiKeyHelper are also removed to migrate older
855
+ # installs that used them for router auth.
856
+ if [ -f "$settings_file" ]; then
857
+ merged="$(jq -s '.[0] as $a | .[1] as $b
858
+ | $a
859
+ | .env = (($a.env // {} | del(.ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS)) + ($b.env // {}))
860
+ | (if (.env | length) == 0 then del(.env) else . end)
861
+ | del(.apiKeyHelper)
862
+ | (if $b.statusLine then .statusLine = $b.statusLine else . end)
863
+ ' "$settings_file" "$tmp_patch")"
864
+ printf '%s\n' "$merged" >"$settings_file"
865
+ else
866
+ cp "$tmp_patch" "$settings_file"
867
+ fi
868
+ ok "Settings written to $settings_file"
869
+
870
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
871
+ jq -n --arg header "$router_key_header: $api_key" '{
872
+ env: { ANTHROPIC_CUSTOM_HEADERS: $header }
873
+ }' >"$tmp_patch"
874
+ if [ -f "$local_settings_file" ]; then
875
+ merged="$(jq -s '.[0] as $a | .[1] as $b
876
+ | $a
877
+ | .env = (($a.env // {} | del(.ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS)) + ($b.env // {}))
878
+ | (if (.env | length) == 0 then del(.env) else . end)
879
+ | del(.apiKeyHelper)
880
+ ' "$local_settings_file" "$tmp_patch")"
881
+ printf '%s\n' "$merged" >"$local_settings_file"
882
+ else
883
+ cp "$tmp_patch" "$local_settings_file"
884
+ fi
885
+ chmod 600 "$local_settings_file"
886
+ ok "Router key header written to $local_settings_file"
887
+ fi
888
+
889
+ # ---------- gitignore for project scope ----------
890
+
891
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
892
+ gitignore="$git_root/.gitignore"
893
+ # Same symlink containment as the .claude/ paths above: a hostile repo could
894
+ # commit .gitignore as a symlink so the >> below writes outside the repo.
895
+ refuse_if_symlink "$gitignore"
896
+ # Keep the statusline script and per-teammate local settings out of git. The
897
+ # local settings carry the router key header; each teammate gets their own.
898
+ for entry in \
899
+ ".claude/settings.local.json" \
900
+ ".claude/.credentials.json" \
901
+ ".claude/cc-statusline.sh"
902
+ do
903
+ if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
904
+ printf '%s\n' "$entry" >>"$gitignore"
905
+ fi
906
+ done
907
+ ok "Updated $gitignore (ignored credentials + local helpers)"
908
+ fi
909
+
910
+ # ---------- post-install verification ----------
911
+
912
+ if [ "$quiet" != "true" ]; then
913
+ if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
914
+ warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
915
+ fi
916
+ fi
917
+
918
+ if [ -n "$api_key" ]; then
919
+ # Pass the router key via stdin (`@-`) instead of a -H argument so the key
920
+ # never appears in the process arg list (visible via `ps` / /proc to other
921
+ # local users on shared machines). We feed stdin via a small wrapper so the
922
+ # spinner's exec form sees a single command argv.
923
+ validate_key() {
924
+ printf '%s: %s\n' "$router_key_header" "$api_key" \
925
+ | curl -fsS --max-time 5 --header @- "$base_url/validate"
926
+ }
927
+ if ! spin "Validating API key" validate_key; then
928
+ warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
929
+ fi
930
+ fi
931
+
932
+ # ---------- done ----------
933
+
934
+ printf "\n"
935
+ printf "%s✓%s %s%sWeave Router installed for Claude Code.%s\n" \
936
+ "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"