@workweave/router 0.2.9 → 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 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. "off"/"on"/
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" = "install" ]; then
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 path's health/validate probes; toggles never
1131
- # hit the network.
1132
- [ "$mode" = "install" ] && require_cmd curl "macOS/Linux: usually preinstalled check your package manager"
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
- case "$(json_get "$1" '.env.ANTHROPIC_CUSTOM_HEADERS')" in
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
- api_key="$WEAVE_ROUTER_KEY"
1663
- info "Using WEAVE_ROUTER_KEY from environment."
1664
- elif [ "$non_interactive" = "true" ]; then
1665
- err "--non-interactive set but WEAVE_ROUTER_KEY is empty. Export it and re-run."
1666
- exit 1
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
- # Read from /dev/tty explicitly so the prompt works under `curl -fsSL ... | sh`,
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
- # Claude Code splits ANTHROPIC_CUSTOM_HEADERS on newlines, so multiple headers
2650
- # ride in the same env var separated by \n. Append identity headers alongside
2651
- # the router key so a single var carries them all. When email/name are empty
2652
- # we keep the bare router-key form so a re-install for a user who opted out
2653
- # cleanly removes the old line.
2654
- custom_headers="$router_key_header: $api_key"
2655
- if [ -n "$user_email" ]; then
2656
- custom_headers="$custom_headers"$'\n'"X-Weave-User-Email: $user_email"
2657
- fi
2658
- if [ -n "$user_name" ]; then
2659
- custom_headers="$custom_headers"$'\n'"X-Weave-User-Name: $user_name"
2660
- fi
2661
- custom_headers="$custom_headers"$'\n'"X-App: claude-code"
2662
-
2663
- # Setting ANTHROPIC_BASE_URL makes Claude Code treat us as non-first-party and
2664
- # disable MCP tool-search deferral, inlining every tool schema into every request
2665
- # a large uncompactable prefix that can push a session into an autocompact
2666
- # thrash loop. ENABLE_TOOL_SEARCH=auto restores on-demand loading (Claude Code's
2667
- # own first-party default).
2668
- if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
2669
- jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
2670
- env: { ANTHROPIC_BASE_URL: $url, ENABLE_TOOL_SEARCH: "auto" },
2671
- statusLine: { type: "command", command: $sl },
2672
- attribution: {
2673
- commit: "Co-Authored-By: Weave Router <router@workweave.ai>",
2674
- pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
2675
- }
2676
- }' >"$tmp_patch"
2677
- else
2678
- jq -n --arg url "$base_url" --arg header "$custom_headers" --arg sl "$statusline_path_for_settings" '{
2679
- env: { ANTHROPIC_BASE_URL: $url, ANTHROPIC_CUSTOM_HEADERS: $header, ENABLE_TOOL_SEARCH: "auto" },
2680
- statusLine: { type: "command", command: $sl },
2681
- attribution: {
2682
- commit: "Co-Authored-By: Weave Router <router@workweave.ai>",
2683
- pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
2684
- }
2685
- }' >"$tmp_patch"
2686
- fi
2687
-
2688
- # Merge with existing settings. Deep-merge env and replace statusLine.
2689
- # We strip router-owned auth from the existing settings BEFORE merging —
2690
- # otherwise switching auth mode (key→dev-mode) would leave stale credentials
2691
- # behind. ANTHROPIC_AUTH_TOKEN/apiKeyHelper are also removed to migrate older
2692
- # installs that used them for router auth.
2693
- if [ -f "$settings_file" ]; then
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
- if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
2712
- jq -n --arg header "$custom_headers" '{
2713
- env: { ANTHROPIC_CUSTOM_HEADERS: $header }
2714
- }' >"$tmp_patch"
2715
- if [ -f "$local_settings_file" ]; then
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
- ' "$local_settings_file" "$tmp_patch")"
2722
- printf '%s\n' "$merged" >"$local_settings_file"
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" "$local_settings_file"
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
- chmod 600 "$local_settings_file"
2727
- ok "Router key header written to $local_settings_file"
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
- warn "Router rejected the API key (check it matches the dashboard at $base_url)."
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.9",
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",