@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/README.md +47 -0
- package/bin.js +55 -0
- package/cc-statusline.sh +365 -0
- package/install.sh +936 -0
- package/package.json +36 -0
- package/uninstall.sh +170 -0
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"
|