@workweave/router 0.2.8 → 0.2.10
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 +16 -0
- package/cc-statusline.sh +151 -0
- package/install.sh +487 -108
- package/package.json +1 -1
- package/uninstall.sh +2 -1
package/README.md
CHANGED
|
@@ -14,6 +14,22 @@ npx @workweave/router --base-url https://router.acme.internal
|
|
|
14
14
|
npx @workweave/router --non-interactive # reads $WEAVE_ROUTER_KEY, no prompts (defaults to claude)
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
Re-running the installer to pick up changes reuses the key already on disk, so
|
|
18
|
+
you paste it once and never again. `update` is the never-prompting form of that
|
|
19
|
+
(safe for cron; errors instead of asking when no key can be found):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @workweave/router --claude # reuses the installed key
|
|
23
|
+
npx @workweave/router --claude --rotate-key # ignore it and prompt for a new one
|
|
24
|
+
npx @workweave/router update --claude # non-interactive refresh in place
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For Claude Code the installed statusline and `/force-model`, `/router-*` slash
|
|
28
|
+
commands also refresh themselves in the background about once a week (never
|
|
29
|
+
overwriting a wrapper you edited). Opt out with `WEAVE_STATUSLINE_UPDATE=0`, or
|
|
30
|
+
just the commands with `WEAVE_COMMANDS_UPDATE=0`. Codex, opencode, and pi have
|
|
31
|
+
no per-turn hook to refresh from — re-run the installer for those.
|
|
32
|
+
|
|
17
33
|
Version-pin for reproducible setups:
|
|
18
34
|
|
|
19
35
|
```bash
|
package/cc-statusline.sh
CHANGED
|
@@ -125,6 +125,157 @@ weave_self_refresh() {
|
|
|
125
125
|
}
|
|
126
126
|
weave_self_refresh 2>/dev/null || true
|
|
127
127
|
|
|
128
|
+
# ---------- background slash-command refresh ----------
|
|
129
|
+
#
|
|
130
|
+
# The /force-model, /router-* … wrappers under <install>/.claude/commands/ are
|
|
131
|
+
# written once at install time and then never touched again, so a user who
|
|
132
|
+
# doesn't re-run the installer keeps whatever set shipped that day. This script
|
|
133
|
+
# is the only thing Claude Code invokes on every turn, so it doubles as the
|
|
134
|
+
# refresh point for them: same rate limit, same detached fork, same
|
|
135
|
+
# content-diff no-op as the self-refresh above.
|
|
136
|
+
#
|
|
137
|
+
# Never clobber a wrapper the user edited. The only way to tell an edit from a
|
|
138
|
+
# stale copy is to remember what we last downloaded, so each canonical file is
|
|
139
|
+
# cached (unrendered) per install under the user cache dir and a wrapper is
|
|
140
|
+
# replaced only when its bytes still match that baseline. With no baseline yet
|
|
141
|
+
# we can't prove anything, so the first run only seeds the cache and a swap can
|
|
142
|
+
# happen from the next one on. install.sh seeds it too, so fresh installs skip
|
|
143
|
+
# that warm-up round.
|
|
144
|
+
#
|
|
145
|
+
# Never touch a wrapper git tracks either. A project-scope install writes the
|
|
146
|
+
# wrappers into the repo's own .claude/commands/, and unlike cc-statusline.sh
|
|
147
|
+
# the installer does not gitignore them — so an unattended weekly rewrite would
|
|
148
|
+
# surface as unexplained dirty files (and could ride along in someone's commit).
|
|
149
|
+
# Only the installer changes tracked files, and only when a human runs it.
|
|
150
|
+
#
|
|
151
|
+
# Opt out with WEAVE_COMMANDS_UPDATE=0 (WEAVE_STATUSLINE_UPDATE=0 disables this
|
|
152
|
+
# too, along with every other network path here). Override the source with
|
|
153
|
+
# WEAVE_COMMANDS_URL_BASE=..., e.g. for self-hosters who fork.
|
|
154
|
+
|
|
155
|
+
# weave_command_tracked_by_git returns 0 when $1 is a file git tracks. Used to
|
|
156
|
+
# leave repo-committed wrappers alone; no git (or no repo) means untracked.
|
|
157
|
+
weave_command_tracked_by_git() {
|
|
158
|
+
command -v git >/dev/null 2>&1 || return 1
|
|
159
|
+
git -C "$(dirname "$1")" ls-files --error-unmatch -- "$1" >/dev/null 2>&1
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# weave_render_command prints $1 with the installer's {{SCOPE}} placeholder
|
|
163
|
+
# replaced by $2, matching how install_slash_commands writes the same file.
|
|
164
|
+
# Trailing newlines are stripped on both sides of every comparison below.
|
|
165
|
+
weave_render_command() {
|
|
166
|
+
local body
|
|
167
|
+
body="$(cat "$1" 2>/dev/null)" || return 1
|
|
168
|
+
printf '%s' "${body//\{\{SCOPE\}\}/$2}"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
weave_sync_commands() {
|
|
172
|
+
[ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
|
|
173
|
+
[ "${WEAVE_COMMANDS_UPDATE:-1}" = "0" ] && return 0
|
|
174
|
+
command -v curl >/dev/null 2>&1 || return 0
|
|
175
|
+
|
|
176
|
+
local self="${BASH_SOURCE[0]:-$0}" self_dir cmd_dir
|
|
177
|
+
self_dir="$(cd "$(dirname "$self")" 2>/dev/null && pwd -P)" || return 0
|
|
178
|
+
# User scope installs this script at <base>/.weave/, project and --dir at
|
|
179
|
+
# <base>/.claude/. Claude Code reads commands from <base>/.claude/commands in
|
|
180
|
+
# both layouts.
|
|
181
|
+
case "${self_dir##*/}" in
|
|
182
|
+
.weave) cmd_dir="${self_dir%/*}/.claude/commands" ;;
|
|
183
|
+
.claude) cmd_dir="$self_dir/commands" ;;
|
|
184
|
+
*) return 0 ;;
|
|
185
|
+
esac
|
|
186
|
+
[ -d "$cmd_dir" ] && [ -w "$cmd_dir" ] || return 0
|
|
187
|
+
|
|
188
|
+
local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router"
|
|
189
|
+
local dir_slug
|
|
190
|
+
dir_slug="$(printf '%s' "$cmd_dir" | tr -c 'A-Za-z0-9._-' '_')"
|
|
191
|
+
# Baseline is keyed by install, not shared: two installs refresh on their own
|
|
192
|
+
# clocks, and a shared baseline updated by one would make the other's still-
|
|
193
|
+
# canonical wrappers look user-edited and freeze them forever.
|
|
194
|
+
local baseline_dir="$cache_dir/commands${dir_slug}"
|
|
195
|
+
local stamp="$cache_dir/checked-at${dir_slug}.commands"
|
|
196
|
+
mkdir -p "$baseline_dir" 2>/dev/null || return 0
|
|
197
|
+
|
|
198
|
+
local interval_days="${WEAVE_STATUSLINE_UPDATE_INTERVAL_DAYS:-7}"
|
|
199
|
+
local now stamp_mtime
|
|
200
|
+
now="$(date +%s 2>/dev/null)" || return 0
|
|
201
|
+
if [ -f "$stamp" ]; then
|
|
202
|
+
stamp_mtime="$(stat -c %Y "$stamp" 2>/dev/null || stat -f %m "$stamp" 2>/dev/null)" || stamp_mtime=0
|
|
203
|
+
else
|
|
204
|
+
stamp_mtime=0
|
|
205
|
+
fi
|
|
206
|
+
if [ -n "${stamp_mtime:-}" ] && [ "$stamp_mtime" -gt 0 ] \
|
|
207
|
+
&& [ $(( now - stamp_mtime )) -lt $(( interval_days * 86400 )) ]; then
|
|
208
|
+
return 0
|
|
209
|
+
fi
|
|
210
|
+
# Stamp before forking, same as the self-refresh: Claude Code calls us on
|
|
211
|
+
# every turn and concurrent invocations must not all start downloading.
|
|
212
|
+
: > "$stamp" 2>/dev/null || return 0
|
|
213
|
+
|
|
214
|
+
# router-off/on/status bake this install's scope selector into their npx
|
|
215
|
+
# line (upstream carries {{SCOPE}} there). Recover it from the installed copy
|
|
216
|
+
# so a project or --dir install keeps toggling its own config; when it can't
|
|
217
|
+
# be recovered those three are skipped rather than rewritten to point at the
|
|
218
|
+
# user-scope install.
|
|
219
|
+
local scope_args="" scope_known="false" off="$cmd_dir/router-off.md"
|
|
220
|
+
if [ -f "$off" ] && grep -q '^`npx @workweave/router off --claude.*`$' "$off" 2>/dev/null; then
|
|
221
|
+
scope_known="true"
|
|
222
|
+
scope_args="$(sed -n 's|^`npx @workweave/router off --claude\(.*\)`$|\1|p' "$off" | head -n 1)"
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
local url_base="${WEAVE_COMMANDS_URL_BASE:-https://raw.githubusercontent.com/workweave/router/main/install/commands}"
|
|
226
|
+
local name installed raw prev tmp new_body prev_body installed_body
|
|
227
|
+
(
|
|
228
|
+
# Detach stdin (CC pipes JSON to us) so curl can't consume it, and silence
|
|
229
|
+
# everything so no output leaks into the statusline.
|
|
230
|
+
exec </dev/null
|
|
231
|
+
for name in force-model unforce-model router-feedback fm ufm rf \
|
|
232
|
+
router-off router-on router-status router-session; do
|
|
233
|
+
installed="$cmd_dir/$name.md"
|
|
234
|
+
# Only ever refresh a wrapper that is already installed: a missing one
|
|
235
|
+
# was uninstalled or deliberately deleted, and resurrecting it would be
|
|
236
|
+
# a surprise. A symlink is user-owned; leave it alone. A git-tracked
|
|
237
|
+
# wrapper belongs to the repo — rewriting it would dirty a working tree
|
|
238
|
+
# nobody asked us to touch.
|
|
239
|
+
[ -f "$installed" ] || continue
|
|
240
|
+
[ -L "$installed" ] && continue
|
|
241
|
+
weave_command_tracked_by_git "$installed" && continue
|
|
242
|
+
|
|
243
|
+
raw="$baseline_dir/$name.md.tmp.$$"
|
|
244
|
+
curl -fsSL --max-time 15 "$url_base/$name.md" -o "$raw" 2>/dev/null || { rm -f "$raw"; continue; }
|
|
245
|
+
# Shape check: every wrapper opens with YAML front matter, so a 404 page
|
|
246
|
+
# or a truncated body can never be installed as a command.
|
|
247
|
+
if [ ! -s "$raw" ] || [ "$(head -n 1 "$raw")" != "---" ]; then
|
|
248
|
+
rm -f "$raw"
|
|
249
|
+
continue
|
|
250
|
+
fi
|
|
251
|
+
|
|
252
|
+
prev="$baseline_dir/$name.md"
|
|
253
|
+
if grep -q '{{SCOPE}}' "$raw" && [ "$scope_known" != "true" ]; then
|
|
254
|
+
mv "$raw" "$prev" 2>/dev/null || rm -f "$raw"
|
|
255
|
+
continue
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
if [ -f "$prev" ]; then
|
|
259
|
+
new_body="$(weave_render_command "$raw" "$scope_args")"
|
|
260
|
+
prev_body="$(weave_render_command "$prev" "$scope_args")"
|
|
261
|
+
installed_body="$(cat "$installed" 2>/dev/null)" || installed_body=""
|
|
262
|
+
if [ "$prev_body" = "$installed_body" ] && [ "$new_body" != "$installed_body" ]; then
|
|
263
|
+
tmp="$installed.tmp.$$"
|
|
264
|
+
if printf '%s\n' "$new_body" >"$tmp" 2>/dev/null; then
|
|
265
|
+
mv "$tmp" "$installed" 2>/dev/null || rm -f "$tmp"
|
|
266
|
+
else
|
|
267
|
+
rm -f "$tmp"
|
|
268
|
+
fi
|
|
269
|
+
fi
|
|
270
|
+
fi
|
|
271
|
+
mv "$raw" "$prev" 2>/dev/null || rm -f "$raw"
|
|
272
|
+
done
|
|
273
|
+
) >/dev/null 2>&1 &
|
|
274
|
+
disown 2>/dev/null || true
|
|
275
|
+
return 0
|
|
276
|
+
}
|
|
277
|
+
weave_sync_commands 2>/dev/null || true
|
|
278
|
+
|
|
128
279
|
input="$(cat)"
|
|
129
280
|
transcript_path="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
|
|
130
281
|
# Prefer model.id over display_name: pricing keys + the routed model id in
|
package/install.sh
CHANGED
|
@@ -43,8 +43,15 @@
|
|
|
43
43
|
# npx @workweave/router --base-url http://localhost:8080 # self-hosted, custom port
|
|
44
44
|
# npx @workweave/router --non-interactive # require WEAVE_ROUTER_KEY env var (defaults target to claude)
|
|
45
45
|
# npx @workweave/router --quiet # suppress banner, ping check, and trailing tips
|
|
46
|
+
# npx @workweave/router --rotate-key # ignore the installed key and prompt for a new one
|
|
46
47
|
# npx @workweave/router --uninstall # remove a previous install (delegates to uninstall.sh)
|
|
47
48
|
#
|
|
49
|
+
# Re-running the installer reuses the key already on disk, so you only paste it
|
|
50
|
+
# once. `update` is the scriptable form of that: it never prompts, refreshes the
|
|
51
|
+
# managed config + assets in place, and errors (rather than asking) when no key
|
|
52
|
+
# can be found:
|
|
53
|
+
# npx @workweave/router update --claude # refresh the Claude Code install in place
|
|
54
|
+
#
|
|
48
55
|
# Toggle an existing install on/off without losing the router config (so
|
|
49
56
|
# switching back is instant). These never prompt for a key and require an
|
|
50
57
|
# explicit client (--claude / --codex / --opencode); they only flip config
|
|
@@ -71,6 +78,7 @@ scope="user"
|
|
|
71
78
|
scope_explicit="false"
|
|
72
79
|
install_dir=""
|
|
73
80
|
base_url=""
|
|
81
|
+
base_url_explicit="false"
|
|
74
82
|
non_interactive="false"
|
|
75
83
|
quiet="false"
|
|
76
84
|
router_key_header="X-Weave-Router-Key"
|
|
@@ -86,12 +94,20 @@ router_key_header="X-Weave-Router-Key"
|
|
|
86
94
|
target="claude"
|
|
87
95
|
target_explicit="false"
|
|
88
96
|
|
|
89
|
-
# Operation mode. "install" (default) writes/refreshes config. "
|
|
97
|
+
# Operation mode. "install" (default) writes/refreshes config. "update" is a
|
|
98
|
+
# never-prompting install that reuses the key already on disk. "off"/"on"/
|
|
90
99
|
# "status" toggle or report an existing install without touching the router
|
|
91
100
|
# key/identity — see the toggle_* helpers and the dispatch block below.
|
|
92
101
|
mode="install"
|
|
93
102
|
disable_routing_alias="false"
|
|
94
103
|
|
|
104
|
+
# --rotate-key forces the interactive key prompt even when a usable key is
|
|
105
|
+
# already installed, so a rotated key can replace it.
|
|
106
|
+
rotate_key="false"
|
|
107
|
+
# Where $api_key came from: env | disk | prompt. Drives the fallback re-prompt
|
|
108
|
+
# when /validate rejects a key we read back off disk.
|
|
109
|
+
api_key_source=""
|
|
110
|
+
|
|
95
111
|
# ---------- helpers ----------
|
|
96
112
|
|
|
97
113
|
# Detect whether stdout is a real terminal that grokks ANSI escapes. Pipes,
|
|
@@ -926,10 +942,12 @@ while [ $# -gt 0 ]; do
|
|
|
926
942
|
--base-url)
|
|
927
943
|
base_url="${2:-}"; shift 2
|
|
928
944
|
[ -n "$base_url" ] || { err "--base-url requires a value."; exit 2; }
|
|
945
|
+
base_url_explicit="true"
|
|
929
946
|
;;
|
|
930
947
|
--local)
|
|
931
948
|
# Shorthand for local dev: localhost:8080 (matches `wv mr` / `make dev` default PORT).
|
|
932
949
|
base_url="http://localhost:8080"
|
|
950
|
+
base_url_explicit="true"
|
|
933
951
|
shift
|
|
934
952
|
;;
|
|
935
953
|
--non-interactive)
|
|
@@ -938,6 +956,9 @@ while [ $# -gt 0 ]; do
|
|
|
938
956
|
--quiet)
|
|
939
957
|
quiet="true"; shift
|
|
940
958
|
;;
|
|
959
|
+
--rotate-key)
|
|
960
|
+
rotate_key="true"; shift
|
|
961
|
+
;;
|
|
941
962
|
--dir)
|
|
942
963
|
install_dir="${2:-}"; shift 2
|
|
943
964
|
[ -n "$install_dir" ] || { err "--dir requires a path."; exit 2; }
|
|
@@ -962,6 +983,11 @@ while [ $# -gt 0 ]; do
|
|
|
962
983
|
# npm wrapper forwards argv verbatim so either form reaches us.
|
|
963
984
|
mode="${1#--}"; shift
|
|
964
985
|
;;
|
|
986
|
+
update|--update)
|
|
987
|
+
# Non-interactive refresh of an existing install. Takes the same target
|
|
988
|
+
# and scope flags as install; resolves the key from env or disk only.
|
|
989
|
+
mode="update"; shift
|
|
990
|
+
;;
|
|
965
991
|
disable-routing|--disable-routing)
|
|
966
992
|
# Convenience alias for the Codex-specific off toggle. Unlike generic
|
|
967
993
|
# `off`, it deliberately resolves the target after all flags are parsed,
|
|
@@ -989,7 +1015,7 @@ fi
|
|
|
989
1015
|
# Toggle verbs only flip config install.sh already wrote: no key, no identity,
|
|
990
1016
|
# no prompts. Require an explicit client so we never guess which config to
|
|
991
1017
|
# touch, and suppress every interactive prompt downstream.
|
|
992
|
-
if [ "$mode" != "install" ]; then
|
|
1018
|
+
if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
|
|
993
1019
|
non_interactive="true"
|
|
994
1020
|
if [ "$target_explicit" != "true" ]; then
|
|
995
1021
|
err "'$mode' requires an explicit client: --claude, --codex, or --opencode."
|
|
@@ -997,6 +1023,22 @@ if [ "$mode" != "install" ]; then
|
|
|
997
1023
|
fi
|
|
998
1024
|
fi
|
|
999
1025
|
|
|
1026
|
+
# `update` is an install that never prompts: it refreshes the managed config
|
|
1027
|
+
# and assets using the key already on disk (or in the environment). Suppressing
|
|
1028
|
+
# prompts here also settles the target/scope pickers below on their defaults,
|
|
1029
|
+
# which is what a cron/script caller wants.
|
|
1030
|
+
if [ "$mode" = "update" ]; then
|
|
1031
|
+
non_interactive="true"
|
|
1032
|
+
if [ "$rotate_key" = "true" ]; then
|
|
1033
|
+
err "--rotate-key needs a prompt, which 'update' never issues. Re-run the installer without 'update'."
|
|
1034
|
+
exit 2
|
|
1035
|
+
fi
|
|
1036
|
+
if [ "$target" != "claude" ]; then
|
|
1037
|
+
err "'update' currently supports --claude only. Re-run the installer for $target (it reuses your installed key)."
|
|
1038
|
+
exit 2
|
|
1039
|
+
fi
|
|
1040
|
+
fi
|
|
1041
|
+
|
|
1000
1042
|
# Toggle verbs (off/on/status) aren't implemented for pi — its config is a
|
|
1001
1043
|
# structural models.json/settings.json merge, reversed by the uninstaller
|
|
1002
1044
|
# rather than a single env/key line we can park and restore.
|
|
@@ -1114,9 +1156,7 @@ fi
|
|
|
1114
1156
|
|
|
1115
1157
|
# ---------- pre-flight ----------
|
|
1116
1158
|
|
|
1117
|
-
if [ "$mode"
|
|
1118
|
-
[ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
|
|
1119
|
-
else
|
|
1159
|
+
if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
|
|
1120
1160
|
[ "$quiet" = "true" ] || info "mode=${C_BOLD}${mode}${C_RESET} scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET}"
|
|
1121
1161
|
fi
|
|
1122
1162
|
|
|
@@ -1127,9 +1167,11 @@ fi
|
|
|
1127
1167
|
if [ "$target" = "claude" ] || [ "$target" = "opencode" ] || [ "$target" = "pi" ]; then
|
|
1128
1168
|
require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
|
|
1129
1169
|
fi
|
|
1130
|
-
# curl is only used by the install
|
|
1131
|
-
# hit the network.
|
|
1132
|
-
[ "$mode" = "install" ]
|
|
1170
|
+
# curl is only used by the install/update paths' health/validate probes;
|
|
1171
|
+
# toggles never hit the network.
|
|
1172
|
+
if [ "$mode" = "install" ] || [ "$mode" = "update" ]; then
|
|
1173
|
+
require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
|
|
1174
|
+
fi
|
|
1133
1175
|
|
|
1134
1176
|
case "$target" in
|
|
1135
1177
|
claude)
|
|
@@ -1331,6 +1373,19 @@ json_get() {
|
|
|
1331
1373
|
jq -r "${2} // empty" "$1" 2>/dev/null || true
|
|
1332
1374
|
}
|
|
1333
1375
|
|
|
1376
|
+
# read_claude_key prints the router key already installed in the given settings
|
|
1377
|
+
# file, or nothing when the file is absent / carries no key header. Claude Code
|
|
1378
|
+
# packs several headers into one newline-delimited ANTHROPIC_CUSTOM_HEADERS
|
|
1379
|
+
# value, so we pick the router-key line out of it and trim the header name.
|
|
1380
|
+
# Read-only and tolerant of every missing-file case — never trips set -e.
|
|
1381
|
+
read_claude_key() {
|
|
1382
|
+
[ -n "${1:-}" ] || return 0
|
|
1383
|
+
json_get "$1" '.env.ANTHROPIC_CUSTOM_HEADERS' \
|
|
1384
|
+
| sed -n "s/^[[:space:]]*${router_key_header}:[[:space:]]*//p" \
|
|
1385
|
+
| head -n 1 \
|
|
1386
|
+
| tr -d '[:space:]'
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1334
1389
|
# claude_key_present returns 0 when the given settings file's
|
|
1335
1390
|
# env.ANTHROPIC_CUSTOM_HEADERS carries the router key header. "On" is only valid
|
|
1336
1391
|
# when this is true: in project scope the committed settings.json holds the
|
|
@@ -1338,10 +1393,7 @@ json_get() {
|
|
|
1338
1393
|
# (or the parked sidecar), so a router URL alone doesn't mean requests can
|
|
1339
1394
|
# authenticate.
|
|
1340
1395
|
claude_key_present() {
|
|
1341
|
-
|
|
1342
|
-
*X-Weave-Router-Key*) return 0 ;;
|
|
1343
|
-
*) return 1 ;;
|
|
1344
|
-
esac
|
|
1396
|
+
[ -n "$(read_claude_key "$1")" ]
|
|
1345
1397
|
}
|
|
1346
1398
|
|
|
1347
1399
|
# gitignore_add appends an entry to the repo .gitignore in project scope so a
|
|
@@ -1646,7 +1698,7 @@ toggle_opencode() {
|
|
|
1646
1698
|
esac
|
|
1647
1699
|
}
|
|
1648
1700
|
|
|
1649
|
-
if [ "$mode" != "install" ]; then
|
|
1701
|
+
if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
|
|
1650
1702
|
case "$target" in
|
|
1651
1703
|
claude) toggle_claude ;;
|
|
1652
1704
|
codex) toggle_codex ;;
|
|
@@ -1655,35 +1707,121 @@ if [ "$mode" != "install" ]; then
|
|
|
1655
1707
|
exit 0
|
|
1656
1708
|
fi
|
|
1657
1709
|
|
|
1710
|
+
# ---------- endpoint carry-over for update ----------
|
|
1711
|
+
#
|
|
1712
|
+
# `update` refreshes an install in place, so it must not silently retarget a
|
|
1713
|
+
# self-hosted or custom endpoint at the hosted default just because --base-url
|
|
1714
|
+
# wasn't repeated. Read the endpoint already configured and keep it; an explicit
|
|
1715
|
+
# --base-url / --local still wins, and install mode is untouched (it is how you
|
|
1716
|
+
# deliberately move an install to a new endpoint).
|
|
1717
|
+
#
|
|
1718
|
+
# `off` parks the router URL and points Claude Code at Anthropic, so the parked
|
|
1719
|
+
# sidecar is the authority while toggled off — reading the live file there would
|
|
1720
|
+
# pin the install to api.anthropic.com.
|
|
1721
|
+
if [ "$mode" = "update" ] && [ "$base_url_explicit" != "true" ] && [ "$target" = "claude" ]; then
|
|
1722
|
+
installed_base=""
|
|
1723
|
+
for candidate in \
|
|
1724
|
+
"$settings_dir/.weave-parked.json" \
|
|
1725
|
+
"$settings_file" \
|
|
1726
|
+
"$local_settings_file"
|
|
1727
|
+
do
|
|
1728
|
+
installed_base="$(json_get "$candidate" '.env.ANTHROPIC_BASE_URL')"
|
|
1729
|
+
router_shaped_url "$installed_base" && break
|
|
1730
|
+
installed_base=""
|
|
1731
|
+
done
|
|
1732
|
+
if [ -n "$installed_base" ]; then
|
|
1733
|
+
base_url="${installed_base%/}"
|
|
1734
|
+
fi
|
|
1735
|
+
fi
|
|
1736
|
+
|
|
1737
|
+
if [ "$mode" = "install" ]; then
|
|
1738
|
+
[ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
|
|
1739
|
+
else
|
|
1740
|
+
[ "$quiet" = "true" ] || info "mode=${C_BOLD}update${C_RESET} scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
|
|
1741
|
+
fi
|
|
1742
|
+
|
|
1658
1743
|
# ---------- token handling ----------
|
|
1744
|
+
#
|
|
1745
|
+
# Resolution order: WEAVE_ROUTER_KEY, then the key this install already wrote to
|
|
1746
|
+
# disk, then an interactive prompt. Reading the installed key back is what makes
|
|
1747
|
+
# a re-run painless — the installer refreshes assets and config shape often
|
|
1748
|
+
# enough that users re-run it routinely, and re-pasting a key every time is pure
|
|
1749
|
+
# friction. --rotate-key skips the read-back so a new key can replace the old.
|
|
1750
|
+
|
|
1751
|
+
# read_installed_key prints the router key this install already has on disk, or
|
|
1752
|
+
# nothing. Only Claude Code is supported today; the other targets still prompt.
|
|
1753
|
+
read_installed_key() {
|
|
1754
|
+
[ "$target" = "claude" ] || return 0
|
|
1755
|
+
local key=""
|
|
1756
|
+
# Mirror where the install path writes the key: project scope (no --dir) puts
|
|
1757
|
+
# it in the gitignored settings.local.json, everything else inlines it into
|
|
1758
|
+
# settings.json. Check the other file too — a scope was possibly changed, or a
|
|
1759
|
+
# project checkout may carry a committed header from an older install.
|
|
1760
|
+
if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
|
|
1761
|
+
key="$(read_claude_key "$local_settings_file")"
|
|
1762
|
+
[ -n "$key" ] || key="$(read_claude_key "$settings_file")"
|
|
1763
|
+
else
|
|
1764
|
+
key="$(read_claude_key "$settings_file")"
|
|
1765
|
+
[ -n "$key" ] || key="$(read_claude_key "$local_settings_file")"
|
|
1766
|
+
fi
|
|
1767
|
+
# Last resort: `off` moves the key header out of the settings files and into
|
|
1768
|
+
# the parked sidecar, so an install/update run while toggled off finds nothing
|
|
1769
|
+
# above even though the key is still on disk. Same {"env":{…}} shape, so
|
|
1770
|
+
# read_claude_key reads it directly.
|
|
1771
|
+
[ -n "$key" ] || key="$(read_claude_key "$settings_dir/.weave-parked.json")"
|
|
1772
|
+
printf '%s' "$key"
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
# prompt_for_key reads a key from the controlling terminal into $api_key. Only
|
|
1776
|
+
# ever called on an interactive install; callers check first.
|
|
1777
|
+
prompt_for_key() {
|
|
1778
|
+
# Read from /dev/tty explicitly so the prompt works under `curl -fsSL ... | sh`,
|
|
1779
|
+
# where stdin is the curl pipe (already at EOF by the time we get here, and
|
|
1780
|
+
# `set -e` would abort on read returning 1).
|
|
1781
|
+
# _spin_cleanup (installed globally above) already restores stty echo on
|
|
1782
|
+
# any exit path, so we don't need a separate trap here — that would
|
|
1783
|
+
# overwrite the spinner cleanup and leak the cursor / child PID on Ctrl-C.
|
|
1784
|
+
printf "%sGet your Weave Router API key at %s%s\n" "$C_BRAND" "$base_url" "$C_RESET"
|
|
1785
|
+
printf "%sPaste your key here (rk_…):%s " "$C_DIM" "$C_RESET"
|
|
1786
|
+
stty -echo </dev/tty 2>/dev/null || true
|
|
1787
|
+
read -r api_key </dev/tty
|
|
1788
|
+
stty echo </dev/tty 2>/dev/null || true
|
|
1789
|
+
printf "\n"
|
|
1790
|
+
api_key_source="prompt"
|
|
1791
|
+
[ -n "$api_key" ] || { err "No key provided."; exit 1; }
|
|
1792
|
+
}
|
|
1659
1793
|
|
|
1660
1794
|
api_key=""
|
|
1795
|
+
installed_key=""
|
|
1796
|
+
[ "$rotate_key" = "true" ] || installed_key="$(read_installed_key)"
|
|
1797
|
+
|
|
1661
1798
|
if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1799
|
+
api_key="$WEAVE_ROUTER_KEY"
|
|
1800
|
+
api_key_source="env"
|
|
1801
|
+
info "Using WEAVE_ROUTER_KEY from environment."
|
|
1802
|
+
elif [ -n "$installed_key" ]; then
|
|
1803
|
+
api_key="$installed_key"
|
|
1804
|
+
api_key_source="disk"
|
|
1805
|
+
[ "$quiet" = "true" ] || info "Reusing the router key already installed (pass --rotate-key to replace it)."
|
|
1806
|
+
elif [ "$mode" = "update" ]; then
|
|
1807
|
+
err "No router key found for $target. Export WEAVE_ROUTER_KEY, or run the installer once to set one up."
|
|
1808
|
+
exit 1
|
|
1809
|
+
elif [ "$non_interactive" = "true" ]; then
|
|
1810
|
+
if [ "$rotate_key" = "true" ]; then
|
|
1811
|
+
err "--rotate-key needs either a prompt or WEAVE_ROUTER_KEY, and neither is available. Export the new key and re-run."
|
|
1667
1812
|
else
|
|
1668
|
-
|
|
1669
|
-
# where stdin is the curl pipe (already at EOF by the time we get here, and
|
|
1670
|
-
# `set -e` would abort on read returning 1). If /dev/tty isn't available
|
|
1671
|
-
# (e.g. CI without a controlling terminal) the user must use --non-interactive.
|
|
1672
|
-
if [ ! -r /dev/tty ]; then
|
|
1673
|
-
err "No controlling terminal — set WEAVE_ROUTER_KEY and re-run with --non-interactive."
|
|
1674
|
-
exit 1
|
|
1675
|
-
fi
|
|
1676
|
-
# _spin_cleanup (installed globally above) already restores stty echo on
|
|
1677
|
-
# any exit path, so we don't need a separate trap here — that would
|
|
1678
|
-
# overwrite the spinner cleanup and leak the cursor / child PID on Ctrl-C.
|
|
1679
|
-
printf "%sGet your Weave Router API key at %s%s\n" "$C_BRAND" "$base_url" "$C_RESET"
|
|
1680
|
-
printf "%sPaste your key here (rk_…):%s " "$C_DIM" "$C_RESET"
|
|
1681
|
-
stty -echo </dev/tty 2>/dev/null || true
|
|
1682
|
-
read -r api_key </dev/tty
|
|
1683
|
-
stty echo </dev/tty 2>/dev/null || true
|
|
1684
|
-
printf "\n"
|
|
1685
|
-
[ -n "$api_key" ] || { err "No key provided."; exit 1; }
|
|
1813
|
+
err "--non-interactive set but WEAVE_ROUTER_KEY is empty and no installed key was found. Export it and re-run."
|
|
1686
1814
|
fi
|
|
1815
|
+
exit 1
|
|
1816
|
+
else
|
|
1817
|
+
# If /dev/tty isn't available (e.g. CI without a controlling terminal) the
|
|
1818
|
+
# user must use --non-interactive.
|
|
1819
|
+
if [ ! -r /dev/tty ]; then
|
|
1820
|
+
err "No controlling terminal — set WEAVE_ROUTER_KEY and re-run with --non-interactive."
|
|
1821
|
+
exit 1
|
|
1822
|
+
fi
|
|
1823
|
+
prompt_for_key
|
|
1824
|
+
fi
|
|
1687
1825
|
|
|
1688
1826
|
# ---------- identity (user email + name) ----------
|
|
1689
1827
|
#
|
|
@@ -1787,9 +1925,40 @@ install_slash_commands() {
|
|
|
1787
1925
|
body="${body//\{\{SCOPE\}\}/$scope_args}"
|
|
1788
1926
|
printf '%s\n' "$body" >"$dst"
|
|
1789
1927
|
done
|
|
1928
|
+
seed_command_baseline "$commands_src_dir" "$cmds"
|
|
1790
1929
|
ok "Slash commands written to $dst_dir ($installed)"
|
|
1791
1930
|
}
|
|
1792
1931
|
|
|
1932
|
+
# seed_command_baseline records the canonical (unrendered) wrapper bodies this
|
|
1933
|
+
# run installed, in the cache dir the statusline's background refresh reads. The
|
|
1934
|
+
# refresh replaces an installed wrapper only when its bytes still match that
|
|
1935
|
+
# baseline, which is how it tells "stale copy" from "user edited it" — without a
|
|
1936
|
+
# seed it has to spend one whole interval establishing one. Claude Code only:
|
|
1937
|
+
# the statusline is the refresh point and it only ever runs there.
|
|
1938
|
+
#
|
|
1939
|
+
# Keyed off the statusline's own location, exactly as the refresh derives it, so
|
|
1940
|
+
# both sides land on the same cache path.
|
|
1941
|
+
seed_command_baseline() {
|
|
1942
|
+
local src_dir="$1" names="$2"
|
|
1943
|
+
[ "$target" = "claude" ] || return 0
|
|
1944
|
+
local sl_dir cmd_dir
|
|
1945
|
+
sl_dir="$(cd "$(dirname "$statusline_file")" 2>/dev/null && pwd -P)" || return 0
|
|
1946
|
+
case "${sl_dir##*/}" in
|
|
1947
|
+
.weave) cmd_dir="${sl_dir%/*}/.claude/commands" ;;
|
|
1948
|
+
.claude) cmd_dir="$sl_dir/commands" ;;
|
|
1949
|
+
*) return 0 ;;
|
|
1950
|
+
esac
|
|
1951
|
+
local dir_slug
|
|
1952
|
+
dir_slug="$(printf '%s' "$cmd_dir" | tr -c 'A-Za-z0-9._-' '_')"
|
|
1953
|
+
local baseline_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router/commands${dir_slug}"
|
|
1954
|
+
mkdir -p "$baseline_dir" 2>/dev/null || return 0
|
|
1955
|
+
local name
|
|
1956
|
+
for name in $names; do
|
|
1957
|
+
[ -f "$src_dir/$name.md" ] || continue
|
|
1958
|
+
cp "$src_dir/$name.md" "$baseline_dir/$name.md" 2>/dev/null || true
|
|
1959
|
+
done
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1793
1962
|
# Codex builds before custom prompt discovery existed were given wrapper files
|
|
1794
1963
|
# under ~/.codex/prompts. They were never invocable as the advertised aliases.
|
|
1795
1964
|
# Remove only byte-for-byte copies of our old wrappers; retain any prompt the
|
|
@@ -2217,6 +2386,157 @@ weave_self_refresh() {
|
|
|
2217
2386
|
}
|
|
2218
2387
|
weave_self_refresh 2>/dev/null || true
|
|
2219
2388
|
|
|
2389
|
+
# ---------- background slash-command refresh ----------
|
|
2390
|
+
#
|
|
2391
|
+
# The /force-model, /router-* … wrappers under <install>/.claude/commands/ are
|
|
2392
|
+
# written once at install time and then never touched again, so a user who
|
|
2393
|
+
# doesn't re-run the installer keeps whatever set shipped that day. This script
|
|
2394
|
+
# is the only thing Claude Code invokes on every turn, so it doubles as the
|
|
2395
|
+
# refresh point for them: same rate limit, same detached fork, same
|
|
2396
|
+
# content-diff no-op as the self-refresh above.
|
|
2397
|
+
#
|
|
2398
|
+
# Never clobber a wrapper the user edited. The only way to tell an edit from a
|
|
2399
|
+
# stale copy is to remember what we last downloaded, so each canonical file is
|
|
2400
|
+
# cached (unrendered) per install under the user cache dir and a wrapper is
|
|
2401
|
+
# replaced only when its bytes still match that baseline. With no baseline yet
|
|
2402
|
+
# we can't prove anything, so the first run only seeds the cache and a swap can
|
|
2403
|
+
# happen from the next one on. install.sh seeds it too, so fresh installs skip
|
|
2404
|
+
# that warm-up round.
|
|
2405
|
+
#
|
|
2406
|
+
# Never touch a wrapper git tracks either. A project-scope install writes the
|
|
2407
|
+
# wrappers into the repo's own .claude/commands/, and unlike cc-statusline.sh
|
|
2408
|
+
# the installer does not gitignore them — so an unattended weekly rewrite would
|
|
2409
|
+
# surface as unexplained dirty files (and could ride along in someone's commit).
|
|
2410
|
+
# Only the installer changes tracked files, and only when a human runs it.
|
|
2411
|
+
#
|
|
2412
|
+
# Opt out with WEAVE_COMMANDS_UPDATE=0 (WEAVE_STATUSLINE_UPDATE=0 disables this
|
|
2413
|
+
# too, along with every other network path here). Override the source with
|
|
2414
|
+
# WEAVE_COMMANDS_URL_BASE=..., e.g. for self-hosters who fork.
|
|
2415
|
+
|
|
2416
|
+
# weave_command_tracked_by_git returns 0 when $1 is a file git tracks. Used to
|
|
2417
|
+
# leave repo-committed wrappers alone; no git (or no repo) means untracked.
|
|
2418
|
+
weave_command_tracked_by_git() {
|
|
2419
|
+
command -v git >/dev/null 2>&1 || return 1
|
|
2420
|
+
git -C "$(dirname "$1")" ls-files --error-unmatch -- "$1" >/dev/null 2>&1
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
# weave_render_command prints $1 with the installer's {{SCOPE}} placeholder
|
|
2424
|
+
# replaced by $2, matching how install_slash_commands writes the same file.
|
|
2425
|
+
# Trailing newlines are stripped on both sides of every comparison below.
|
|
2426
|
+
weave_render_command() {
|
|
2427
|
+
local body
|
|
2428
|
+
body="$(cat "$1" 2>/dev/null)" || return 1
|
|
2429
|
+
printf '%s' "${body//\{\{SCOPE\}\}/$2}"
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
weave_sync_commands() {
|
|
2433
|
+
[ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
|
|
2434
|
+
[ "${WEAVE_COMMANDS_UPDATE:-1}" = "0" ] && return 0
|
|
2435
|
+
command -v curl >/dev/null 2>&1 || return 0
|
|
2436
|
+
|
|
2437
|
+
local self="${BASH_SOURCE[0]:-$0}" self_dir cmd_dir
|
|
2438
|
+
self_dir="$(cd "$(dirname "$self")" 2>/dev/null && pwd -P)" || return 0
|
|
2439
|
+
# User scope installs this script at <base>/.weave/, project and --dir at
|
|
2440
|
+
# <base>/.claude/. Claude Code reads commands from <base>/.claude/commands in
|
|
2441
|
+
# both layouts.
|
|
2442
|
+
case "${self_dir##*/}" in
|
|
2443
|
+
.weave) cmd_dir="${self_dir%/*}/.claude/commands" ;;
|
|
2444
|
+
.claude) cmd_dir="$self_dir/commands" ;;
|
|
2445
|
+
*) return 0 ;;
|
|
2446
|
+
esac
|
|
2447
|
+
[ -d "$cmd_dir" ] && [ -w "$cmd_dir" ] || return 0
|
|
2448
|
+
|
|
2449
|
+
local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router"
|
|
2450
|
+
local dir_slug
|
|
2451
|
+
dir_slug="$(printf '%s' "$cmd_dir" | tr -c 'A-Za-z0-9._-' '_')"
|
|
2452
|
+
# Baseline is keyed by install, not shared: two installs refresh on their own
|
|
2453
|
+
# clocks, and a shared baseline updated by one would make the other's still-
|
|
2454
|
+
# canonical wrappers look user-edited and freeze them forever.
|
|
2455
|
+
local baseline_dir="$cache_dir/commands${dir_slug}"
|
|
2456
|
+
local stamp="$cache_dir/checked-at${dir_slug}.commands"
|
|
2457
|
+
mkdir -p "$baseline_dir" 2>/dev/null || return 0
|
|
2458
|
+
|
|
2459
|
+
local interval_days="${WEAVE_STATUSLINE_UPDATE_INTERVAL_DAYS:-7}"
|
|
2460
|
+
local now stamp_mtime
|
|
2461
|
+
now="$(date +%s 2>/dev/null)" || return 0
|
|
2462
|
+
if [ -f "$stamp" ]; then
|
|
2463
|
+
stamp_mtime="$(stat -c %Y "$stamp" 2>/dev/null || stat -f %m "$stamp" 2>/dev/null)" || stamp_mtime=0
|
|
2464
|
+
else
|
|
2465
|
+
stamp_mtime=0
|
|
2466
|
+
fi
|
|
2467
|
+
if [ -n "${stamp_mtime:-}" ] && [ "$stamp_mtime" -gt 0 ] \
|
|
2468
|
+
&& [ $(( now - stamp_mtime )) -lt $(( interval_days * 86400 )) ]; then
|
|
2469
|
+
return 0
|
|
2470
|
+
fi
|
|
2471
|
+
# Stamp before forking, same as the self-refresh: Claude Code calls us on
|
|
2472
|
+
# every turn and concurrent invocations must not all start downloading.
|
|
2473
|
+
: > "$stamp" 2>/dev/null || return 0
|
|
2474
|
+
|
|
2475
|
+
# router-off/on/status bake this install's scope selector into their npx
|
|
2476
|
+
# line (upstream carries {{SCOPE}} there). Recover it from the installed copy
|
|
2477
|
+
# so a project or --dir install keeps toggling its own config; when it can't
|
|
2478
|
+
# be recovered those three are skipped rather than rewritten to point at the
|
|
2479
|
+
# user-scope install.
|
|
2480
|
+
local scope_args="" scope_known="false" off="$cmd_dir/router-off.md"
|
|
2481
|
+
if [ -f "$off" ] && grep -q '^`npx @workweave/router off --claude.*`$' "$off" 2>/dev/null; then
|
|
2482
|
+
scope_known="true"
|
|
2483
|
+
scope_args="$(sed -n 's|^`npx @workweave/router off --claude\(.*\)`$|\1|p' "$off" | head -n 1)"
|
|
2484
|
+
fi
|
|
2485
|
+
|
|
2486
|
+
local url_base="${WEAVE_COMMANDS_URL_BASE:-https://raw.githubusercontent.com/workweave/router/main/install/commands}"
|
|
2487
|
+
local name installed raw prev tmp new_body prev_body installed_body
|
|
2488
|
+
(
|
|
2489
|
+
# Detach stdin (CC pipes JSON to us) so curl can't consume it, and silence
|
|
2490
|
+
# everything so no output leaks into the statusline.
|
|
2491
|
+
exec </dev/null
|
|
2492
|
+
for name in force-model unforce-model router-feedback fm ufm rf \
|
|
2493
|
+
router-off router-on router-status router-session; do
|
|
2494
|
+
installed="$cmd_dir/$name.md"
|
|
2495
|
+
# Only ever refresh a wrapper that is already installed: a missing one
|
|
2496
|
+
# was uninstalled or deliberately deleted, and resurrecting it would be
|
|
2497
|
+
# a surprise. A symlink is user-owned; leave it alone. A git-tracked
|
|
2498
|
+
# wrapper belongs to the repo — rewriting it would dirty a working tree
|
|
2499
|
+
# nobody asked us to touch.
|
|
2500
|
+
[ -f "$installed" ] || continue
|
|
2501
|
+
[ -L "$installed" ] && continue
|
|
2502
|
+
weave_command_tracked_by_git "$installed" && continue
|
|
2503
|
+
|
|
2504
|
+
raw="$baseline_dir/$name.md.tmp.$$"
|
|
2505
|
+
curl -fsSL --max-time 15 "$url_base/$name.md" -o "$raw" 2>/dev/null || { rm -f "$raw"; continue; }
|
|
2506
|
+
# Shape check: every wrapper opens with YAML front matter, so a 404 page
|
|
2507
|
+
# or a truncated body can never be installed as a command.
|
|
2508
|
+
if [ ! -s "$raw" ] || [ "$(head -n 1 "$raw")" != "---" ]; then
|
|
2509
|
+
rm -f "$raw"
|
|
2510
|
+
continue
|
|
2511
|
+
fi
|
|
2512
|
+
|
|
2513
|
+
prev="$baseline_dir/$name.md"
|
|
2514
|
+
if grep -q '{{SCOPE}}' "$raw" && [ "$scope_known" != "true" ]; then
|
|
2515
|
+
mv "$raw" "$prev" 2>/dev/null || rm -f "$raw"
|
|
2516
|
+
continue
|
|
2517
|
+
fi
|
|
2518
|
+
|
|
2519
|
+
if [ -f "$prev" ]; then
|
|
2520
|
+
new_body="$(weave_render_command "$raw" "$scope_args")"
|
|
2521
|
+
prev_body="$(weave_render_command "$prev" "$scope_args")"
|
|
2522
|
+
installed_body="$(cat "$installed" 2>/dev/null)" || installed_body=""
|
|
2523
|
+
if [ "$prev_body" = "$installed_body" ] && [ "$new_body" != "$installed_body" ]; then
|
|
2524
|
+
tmp="$installed.tmp.$$"
|
|
2525
|
+
if printf '%s\n' "$new_body" >"$tmp" 2>/dev/null; then
|
|
2526
|
+
mv "$tmp" "$installed" 2>/dev/null || rm -f "$tmp"
|
|
2527
|
+
else
|
|
2528
|
+
rm -f "$tmp"
|
|
2529
|
+
fi
|
|
2530
|
+
fi
|
|
2531
|
+
fi
|
|
2532
|
+
mv "$raw" "$prev" 2>/dev/null || rm -f "$raw"
|
|
2533
|
+
done
|
|
2534
|
+
) >/dev/null 2>&1 &
|
|
2535
|
+
disown 2>/dev/null || true
|
|
2536
|
+
return 0
|
|
2537
|
+
}
|
|
2538
|
+
weave_sync_commands 2>/dev/null || true
|
|
2539
|
+
|
|
2220
2540
|
input="$(cat)"
|
|
2221
2541
|
transcript_path="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
|
|
2222
2542
|
# Prefer model.id over display_name: pricing keys + the routed model id in
|
|
@@ -2646,87 +2966,114 @@ tmp_patch="$(mktemp)"
|
|
|
2646
2966
|
# leave the cursor hidden if Ctrl-C lands during settings.json patching.
|
|
2647
2967
|
trap '_spin_cleanup; rm -f "$tmp_patch"' EXIT INT TERM HUP
|
|
2648
2968
|
|
|
2649
|
-
#
|
|
2650
|
-
#
|
|
2651
|
-
#
|
|
2652
|
-
#
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
custom_headers="$
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
merged="$(jq -s '.[0] as $a | .[1] as $b
|
|
2695
|
-
| $a
|
|
2696
|
-
| .env = (($a.env // {} | del(.ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS)) + ($b.env // {}))
|
|
2697
|
-
| (if (.env | length) == 0 then del(.env) else . end)
|
|
2698
|
-
| del(.apiKeyHelper)
|
|
2699
|
-
| (if $b.statusLine then .statusLine = $b.statusLine else . end)
|
|
2700
|
-
| (if $b.attribution then .attribution = $b.attribution else . end)
|
|
2701
|
-
' "$settings_file" "$tmp_patch")"
|
|
2702
|
-
printf '%s\n' "$merged" >"$settings_file"
|
|
2703
|
-
else
|
|
2704
|
-
cp "$tmp_patch" "$settings_file"
|
|
2705
|
-
fi
|
|
2706
|
-
ok "Settings written to $settings_file"
|
|
2707
|
-
|
|
2708
|
-
# Slash command wrappers — see install_slash_commands() below for the why.
|
|
2709
|
-
install_slash_commands "$settings_dir/commands"
|
|
2969
|
+
# write_claude_settings merges the router config into settings.json (and, in
|
|
2970
|
+
# project scope, the key header into settings.local.json) for the key in $1.
|
|
2971
|
+
# Factored out so the /validate fallback can rewrite both files with a
|
|
2972
|
+
# replacement key without re-running the whole install.
|
|
2973
|
+
write_claude_settings() {
|
|
2974
|
+
local block_key="$1"
|
|
2975
|
+
|
|
2976
|
+
# Claude Code splits ANTHROPIC_CUSTOM_HEADERS on newlines, so multiple headers
|
|
2977
|
+
# ride in the same env var separated by \n. Append identity headers alongside
|
|
2978
|
+
# the router key so a single var carries them all. When email/name are empty
|
|
2979
|
+
# we keep the bare router-key form so a re-install for a user who opted out
|
|
2980
|
+
# cleanly removes the old line.
|
|
2981
|
+
local custom_headers="$router_key_header: $block_key"
|
|
2982
|
+
if [ -n "$user_email" ]; then
|
|
2983
|
+
custom_headers="$custom_headers"$'\n'"X-Weave-User-Email: $user_email"
|
|
2984
|
+
fi
|
|
2985
|
+
if [ -n "$user_name" ]; then
|
|
2986
|
+
custom_headers="$custom_headers"$'\n'"X-Weave-User-Name: $user_name"
|
|
2987
|
+
fi
|
|
2988
|
+
custom_headers="$custom_headers"$'\n'"X-App: claude-code"
|
|
2989
|
+
|
|
2990
|
+
# Setting ANTHROPIC_BASE_URL makes Claude Code treat us as non-first-party and
|
|
2991
|
+
# disable MCP tool-search deferral, inlining every tool schema into every request
|
|
2992
|
+
# — a large uncompactable prefix that can push a session into an autocompact
|
|
2993
|
+
# thrash loop. ENABLE_TOOL_SEARCH=auto restores on-demand loading (Claude Code's
|
|
2994
|
+
# own first-party default).
|
|
2995
|
+
if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
|
|
2996
|
+
jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
|
|
2997
|
+
env: { ANTHROPIC_BASE_URL: $url, ENABLE_TOOL_SEARCH: "auto" },
|
|
2998
|
+
statusLine: { type: "command", command: $sl },
|
|
2999
|
+
attribution: {
|
|
3000
|
+
commit: "Co-Authored-By: Weave Router <router@workweave.ai>",
|
|
3001
|
+
pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
|
|
3002
|
+
}
|
|
3003
|
+
}' >"$tmp_patch"
|
|
3004
|
+
else
|
|
3005
|
+
jq -n --arg url "$base_url" --arg header "$custom_headers" --arg sl "$statusline_path_for_settings" '{
|
|
3006
|
+
env: { ANTHROPIC_BASE_URL: $url, ANTHROPIC_CUSTOM_HEADERS: $header, ENABLE_TOOL_SEARCH: "auto" },
|
|
3007
|
+
statusLine: { type: "command", command: $sl },
|
|
3008
|
+
attribution: {
|
|
3009
|
+
commit: "Co-Authored-By: Weave Router <router@workweave.ai>",
|
|
3010
|
+
pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
|
|
3011
|
+
}
|
|
3012
|
+
}' >"$tmp_patch"
|
|
3013
|
+
fi
|
|
2710
3014
|
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
3015
|
+
# Merge with existing settings. Deep-merge env and replace statusLine.
|
|
3016
|
+
# We strip router-owned auth from the existing settings BEFORE merging —
|
|
3017
|
+
# otherwise switching auth mode (key→dev-mode) would leave stale credentials
|
|
3018
|
+
# behind. ANTHROPIC_AUTH_TOKEN/apiKeyHelper are also removed to migrate older
|
|
3019
|
+
# installs that used them for router auth.
|
|
3020
|
+
local merged
|
|
3021
|
+
if [ -f "$settings_file" ]; then
|
|
2716
3022
|
merged="$(jq -s '.[0] as $a | .[1] as $b
|
|
2717
3023
|
| $a
|
|
2718
3024
|
| .env = (($a.env // {} | del(.ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS)) + ($b.env // {}))
|
|
2719
3025
|
| (if (.env | length) == 0 then del(.env) else . end)
|
|
2720
3026
|
| del(.apiKeyHelper)
|
|
2721
|
-
|
|
2722
|
-
|
|
3027
|
+
| (if $b.statusLine then .statusLine = $b.statusLine else . end)
|
|
3028
|
+
| (if $b.attribution then .attribution = $b.attribution else . end)
|
|
3029
|
+
' "$settings_file" "$tmp_patch")"
|
|
3030
|
+
printf '%s\n' "$merged" >"$settings_file"
|
|
2723
3031
|
else
|
|
2724
|
-
cp "$tmp_patch" "$
|
|
3032
|
+
cp "$tmp_patch" "$settings_file"
|
|
3033
|
+
fi
|
|
3034
|
+
ok "Settings written to $settings_file"
|
|
3035
|
+
|
|
3036
|
+
if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
|
|
3037
|
+
jq -n --arg header "$custom_headers" '{
|
|
3038
|
+
env: { ANTHROPIC_CUSTOM_HEADERS: $header }
|
|
3039
|
+
}' >"$tmp_patch"
|
|
3040
|
+
if [ -f "$local_settings_file" ]; then
|
|
3041
|
+
# Also strip ANTHROPIC_BASE_URL: `off` in project scope points this file
|
|
3042
|
+
# straight at Anthropic (the committed settings.json carries the router
|
|
3043
|
+
# URL instead). Leaving that override in place would have every request
|
|
3044
|
+
# keep bypassing the router even though this install/update just wrote
|
|
3045
|
+
# a fresh key and reports success.
|
|
3046
|
+
merged="$(jq -s '.[0] as $a | .[1] as $b
|
|
3047
|
+
| $a
|
|
3048
|
+
| .env = (($a.env // {} | del(.ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS, .ANTHROPIC_BASE_URL)) + ($b.env // {}))
|
|
3049
|
+
| (if (.env | length) == 0 then del(.env) else . end)
|
|
3050
|
+
| del(.apiKeyHelper)
|
|
3051
|
+
' "$local_settings_file" "$tmp_patch")"
|
|
3052
|
+
printf '%s\n' "$merged" >"$local_settings_file"
|
|
3053
|
+
else
|
|
3054
|
+
cp "$tmp_patch" "$local_settings_file"
|
|
3055
|
+
fi
|
|
3056
|
+
chmod 600 "$local_settings_file"
|
|
3057
|
+
ok "Router key header written to $local_settings_file"
|
|
2725
3058
|
fi
|
|
2726
|
-
|
|
2727
|
-
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
# write_claude_settings rewrites the full router config live, so a parked
|
|
3062
|
+
# sidecar left over from `off` is redundant afterward: the router is back on
|
|
3063
|
+
# and the key/base URL it preserved are in the settings files. Leave it in
|
|
3064
|
+
# place and `status` would keep reporting off, and `off` would no-op against
|
|
3065
|
+
# live router config the user can no longer toggle. Removed AFTER the write
|
|
3066
|
+
# succeeds (not before) — deleting it first would lose the only on-disk copy
|
|
3067
|
+
# of the key if the write itself failed midway under `set -e`.
|
|
3068
|
+
write_claude_settings "$api_key"
|
|
3069
|
+
|
|
3070
|
+
if [ "$target" = "claude" ] && [ -f "$settings_dir/.weave-parked.json" ]; then
|
|
3071
|
+
rm -f "$settings_dir/.weave-parked.json"
|
|
2728
3072
|
fi
|
|
2729
3073
|
|
|
3074
|
+
# Slash command wrappers — see install_slash_commands() below for the why.
|
|
3075
|
+
install_slash_commands "$settings_dir/commands"
|
|
3076
|
+
|
|
2730
3077
|
# ---------- gitignore for project scope ----------
|
|
2731
3078
|
|
|
2732
3079
|
if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
|
|
@@ -2766,13 +3113,45 @@ if [ -n "$api_key" ]; then
|
|
|
2766
3113
|
| curl -fsS --max-time 5 --header @- "$base_url/validate"
|
|
2767
3114
|
}
|
|
2768
3115
|
if ! spin "Validating API key" validate_key; then
|
|
2769
|
-
|
|
3116
|
+
# A key we read back off disk can have been revoked or rotated since it was
|
|
3117
|
+
# installed, and reusing it silently would leave a broken install behind
|
|
3118
|
+
# exactly where the old behavior (always prompt) would have fixed it. Ask
|
|
3119
|
+
# once, then rewrite the settings with whatever the user pastes. Anywhere a
|
|
3120
|
+
# prompt isn't available — non-interactive, update, --quiet, no tty, or a
|
|
3121
|
+
# key that didn't come from disk — keep the historical warn-and-continue.
|
|
3122
|
+
if [ "$api_key_source" = "disk" ] && [ "$non_interactive" != "true" ] \
|
|
3123
|
+
&& [ "$quiet" != "true" ] && [ -r /dev/tty ]; then
|
|
3124
|
+
warn "The router key already installed was rejected (revoked or rotated)."
|
|
3125
|
+
prompt_for_key
|
|
3126
|
+
write_claude_settings "$api_key"
|
|
3127
|
+
if ! spin "Validating API key" validate_key; then
|
|
3128
|
+
warn "Router rejected the API key (check it matches the dashboard at $base_url)."
|
|
3129
|
+
fi
|
|
3130
|
+
elif [ "$mode" = "update" ]; then
|
|
3131
|
+
# update is meant for cron/scripting: a rejected key is a real failure,
|
|
3132
|
+
# not a note in the log. --quiet callers opted out of the noise, not out
|
|
3133
|
+
# of the nonzero exit — a scheduled run that reports success here would
|
|
3134
|
+
# hide a revoked key indefinitely.
|
|
3135
|
+
if [ "$quiet" = "true" ]; then
|
|
3136
|
+
warn "Router rejected the installed API key. Re-run the installer to set a new one."
|
|
3137
|
+
else
|
|
3138
|
+
err "Router rejected the installed API key. Re-run the installer to set a new one."
|
|
3139
|
+
fi
|
|
3140
|
+
exit 1
|
|
3141
|
+
else
|
|
3142
|
+
warn "Router rejected the API key (check it matches the dashboard at $base_url)."
|
|
3143
|
+
fi
|
|
2770
3144
|
fi
|
|
2771
3145
|
fi
|
|
2772
3146
|
|
|
2773
3147
|
# ---------- done ----------
|
|
2774
3148
|
|
|
2775
3149
|
printf "\n"
|
|
3150
|
+
if [ "$mode" = "update" ]; then
|
|
3151
|
+
printf "%s✓%s %s%sWeave Router config refreshed for Claude Code.%s\n" \
|
|
3152
|
+
"$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
|
|
3153
|
+
exit 0
|
|
3154
|
+
fi
|
|
2776
3155
|
printf "%s✓%s %s%sWeave Router installed for Claude Code.%s\n" \
|
|
2777
3156
|
"$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
|
|
2778
3157
|
print_uninstall_hint
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workweave/router",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "One-command installer that points Claude Code, Codex, opencode, or pi at the Weave Router. For pi it also ships the routing extension, loaded via pi.extensions.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"weave-router": "bin.js",
|
package/uninstall.sh
CHANGED
|
@@ -545,7 +545,8 @@ if [ -f "$settings_file" ]; then
|
|
|
545
545
|
then del(.statusLine) else . end)
|
|
546
546
|
| (if (.apiKeyHelper // "" | tostring | endswith("weave-key.sh"))
|
|
547
547
|
then del(.apiKeyHelper) else . end)
|
|
548
|
-
| (if (.attribution.commit == "Co-Authored-By: Weave Router <
|
|
548
|
+
| (if ((.attribution.commit == "Co-Authored-By: Weave Router <router@workweave.ai>"
|
|
549
|
+
or .attribution.commit == "Co-Authored-By: Weave Router <noreply@workweave.ai>")
|
|
549
550
|
and .attribution.pr == "🤖 Generated with [Weave Router](https://router.workweave.ai)")
|
|
550
551
|
then del(.attribution) else . end)
|
|
551
552
|
' "$settings_file")"
|