@workweave/router 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/install.sh CHANGED
@@ -37,14 +37,23 @@
37
37
  # npx @workweave/router --codex # skip the picker, target Codex
38
38
  # npx @workweave/router --opencode # skip the picker, target opencode
39
39
  # npx @workweave/router --pi # skip the picker, target pi
40
+ # npx @workweave/router --pi --lsp go,typescript # also install language servers for pi's lsp tool
41
+ # (go/typescript/python/rust; needs that language's toolchain)
40
42
  # npx @workweave/router --scope project # commit-with-team install
41
43
  # npx @workweave/router --dir /tmp/my-sandbox # isolated throwaway install
42
44
  # npx @workweave/router --local # local router on localhost:8080
43
45
  # npx @workweave/router --base-url http://localhost:8080 # self-hosted, custom port
44
46
  # npx @workweave/router --non-interactive # require WEAVE_ROUTER_KEY env var (defaults target to claude)
45
47
  # npx @workweave/router --quiet # suppress banner, ping check, and trailing tips
48
+ # npx @workweave/router --rotate-key # ignore the installed key and prompt for a new one
46
49
  # npx @workweave/router --uninstall # remove a previous install (delegates to uninstall.sh)
47
50
  #
51
+ # Re-running the installer reuses the key already on disk, so you only paste it
52
+ # once. `update` is the scriptable form of that: it never prompts, refreshes the
53
+ # managed config + assets in place, and errors (rather than asking) when no key
54
+ # can be found:
55
+ # npx @workweave/router update --claude # refresh the Claude Code install in place
56
+ #
48
57
  # Toggle an existing install on/off without losing the router config (so
49
58
  # switching back is instant). These never prompt for a key and require an
50
59
  # explicit client (--claude / --codex / --opencode); they only flip config
@@ -57,6 +66,28 @@
57
66
  # `claude` start; Codex and opencode re-read config every invocation.
58
67
  # Cursor's base URL lives in its own settings UI (no file we own), so there's
59
68
  # nothing to toggle here — flip "Override OpenAI Base URL" in Cursor settings.
69
+ #
70
+ # Which router directives each client gets is declared once in
71
+ # install/directives.tsv and installed from there: Claude Code and opencode get
72
+ # Markdown slash commands, Codex gets native `$name` skills (it reserves `/…`
73
+ # for built-ins), pi registers /fm and /ufm in its extension, and Cursor is
74
+ # manual. See install/README.md for the full matrix.
75
+ #
76
+ # Inspect and edit which models this installation lets the router pick from —
77
+ # the same lists the router dashboard's settings page renders. The endpoint and
78
+ # router key both come from the install already on disk, so nothing is prompted
79
+ # and no key is ever passed on the command line:
80
+ # npx @workweave/router models --claude # every model, with its on/off state
81
+ # npx @workweave/router models disable gpt-5.6 --claude # take a model out of rotation
82
+ # npx @workweave/router models enable gpt-5.6 --claude # put it back
83
+ # npx @workweave/router models providers --claude # same, one row per provider
84
+ # npx @workweave/router models providers disable openai --claude # drop a whole provider
85
+ # npx @workweave/router models prefer claude-opus-5 --claude # set the preferred-model ranking
86
+ # npx @workweave/router models list --json --claude # machine-readable, for scripts
87
+ # Editing needs a router that mounts the model-selection API (self-hosted and
88
+ # local routers do). The Weave-hosted router keeps model selection with the
89
+ # organization, in the Weave dashboard — there `models` lists and points you at
90
+ # it rather than editing.
60
91
 
61
92
  set -euo pipefail
62
93
 
@@ -71,6 +102,7 @@ scope="user"
71
102
  scope_explicit="false"
72
103
  install_dir=""
73
104
  base_url=""
105
+ base_url_explicit="false"
74
106
  non_interactive="false"
75
107
  quiet="false"
76
108
  router_key_header="X-Weave-Router-Key"
@@ -86,12 +118,33 @@ router_key_header="X-Weave-Router-Key"
86
118
  target="claude"
87
119
  target_explicit="false"
88
120
 
89
- # Operation mode. "install" (default) writes/refreshes config. "off"/"on"/
121
+ # Operation mode. "install" (default) writes/refreshes config. "update" is a
122
+ # never-prompting install that reuses the key already on disk. "off"/"on"/
90
123
  # "status" toggle or report an existing install without touching the router
91
124
  # key/identity — see the toggle_* helpers and the dispatch block below.
125
+ # "models" reads and edits the installation's model selection over the router's
126
+ # admin API; it touches no local config at all.
92
127
  mode="install"
93
128
  disable_routing_alias="false"
94
129
 
130
+ # `models` sub-verb plus its operands (model / provider ids), newline-delimited
131
+ # in argument order. Catalog ids and provider names never contain whitespace,
132
+ # so a newline-delimited string stays readable where a bash 3.2 array under
133
+ # `set -u` would not. --json switches the output to the raw API payload.
134
+ models_args=""
135
+ models_json="false"
136
+
137
+ # --lsp <langs>: comma-separated languages whose language servers to install
138
+ # for the pi extension's `lsp` tool (pi target only). Empty = don't install any.
139
+ lsp_langs=""
140
+
141
+ # --rotate-key forces the interactive key prompt even when a usable key is
142
+ # already installed, so a rotated key can replace it.
143
+ rotate_key="false"
144
+ # Where $api_key came from: env | disk | prompt. Drives the fallback re-prompt
145
+ # when /validate rejects a key we read back off disk.
146
+ api_key_source=""
147
+
95
148
  # ---------- helpers ----------
96
149
 
97
150
  # Detect whether stdout is a real terminal that grokks ANSI escapes. Pipes,
@@ -470,14 +523,9 @@ write_codex_config() {
470
523
  # that share the same router key. The router otherwise has to guess from
471
524
  # User-Agent.
472
525
  headers_parts="${headers_parts}, \"X-App\" = \"codex\""
473
- # Match the public hosted Claude Code installation: Codex needs this
474
- # explicit policy selection because that endpoint otherwise uses its
475
- # cluster default. Self-hosted endpoints may not run the optional HMM
476
- # sidecar, so every custom URL deliberately keeps the router's own default.
477
- # Keep this inside the managed block so a re-install can change it safely.
478
- if [ "$block_url" = "$HOSTED_BASE_URL" ]; then
479
- headers_parts="${headers_parts}, \"X-Weave-Router-Strategy\" = \"hmm\""
480
- fi
526
+ # No strategy header: pinning one here freezes installed clients on whatever
527
+ # policy was current at install time, so a deployment-default change never
528
+ # reaches them. Every endpoint, hosted or self-hosted, uses its own default.
481
529
  local headers_line="http_headers = { ${headers_parts} }"
482
530
 
483
531
  local block
@@ -787,8 +835,8 @@ write_pi_models_config() {
787
835
  models: [
788
836
  { id: "claude-fable-5", name: "Claude Fable 5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 128000 },
789
837
  { id: "claude-opus-5", name: "Claude Opus 5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 128000 },
790
- { id: "claude-opus-4-7", name: "Claude Opus 4.7 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
791
- { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
838
+ { id: "claude-opus-4-7", name: "Claude Opus 4.7 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 64000 },
839
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 64000 },
792
840
  { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 32000 },
793
841
  { id: "gpt-5.6-sol", name: "GPT-5.6 Sol (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1050000, maxTokens: 128000 },
794
842
  { id: "grok-4.5", name: "Grok 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 500000, maxTokens: 131072 },
@@ -843,6 +891,69 @@ write_pi_settings_config() {
843
891
  printf '%s\n' "$merged" >"$settings_file"
844
892
  }
845
893
 
894
+ # Language-server installs for the pi extension's `lsp` tool (--lsp go,ts,...).
895
+ # The alias -> server -> toolchain matrix mirrors LSP_SERVERS in
896
+ # install/pi-router/src/lsp-servers.ts — keep the two in lockstep. Every
897
+ # failure is a warn-and-continue: a missing toolchain must not fail the router
898
+ # install, and the extension re-offers conversationally at session start.
899
+ install_lsp_servers() {
900
+ local langs="$1" lang
901
+ local seen=" "
902
+ for lang in $(printf '%s' "$langs" | tr ',' ' '); do
903
+ local id="" bin="" alt_bin="" toolchain="" fallback_dir="" gopath=""
904
+ local cmd=""
905
+ case "$(printf '%s' "$lang" | tr '[:upper:]' '[:lower:]')" in
906
+ go|golang)
907
+ id="go"; bin="gopls"; toolchain="go"
908
+ # go install honors $GOBIN, else <first GOPATH element>/bin (default ~/go/bin).
909
+ gopath="${GOPATH:-$HOME/go}"
910
+ fallback_dir="${GOBIN:-${gopath%%:*}/bin}"
911
+ cmd="go install golang.org/x/tools/gopls@latest"
912
+ ;;
913
+ ts|typescript|js|javascript)
914
+ id="typescript"; bin="typescript-language-server"; toolchain="npm"
915
+ cmd="npm i -g typescript-language-server typescript"
916
+ ;;
917
+ py|python)
918
+ id="python"; bin="pyright-langserver"; alt_bin="basedpyright-langserver"; toolchain="npm"
919
+ cmd="npm i -g pyright"
920
+ ;;
921
+ rs|rust)
922
+ id="rust"; bin="rust-analyzer"; toolchain="rustup"
923
+ # rustup honors $CARGO_HOME (default ~/.cargo).
924
+ fallback_dir="${CARGO_HOME:-$HOME/.cargo}/bin"
925
+ cmd="rustup component add rust-analyzer"
926
+ ;;
927
+ *)
928
+ warn "--lsp: unknown language '$lang' (valid: go, typescript, python, rust). Skipping."
929
+ continue
930
+ ;;
931
+ esac
932
+ case "$seen" in *" $id "*) continue ;; esac
933
+ seen="$seen$id "
934
+
935
+ if command -v "$bin" >/dev/null 2>&1 \
936
+ || { [ -n "$alt_bin" ] && command -v "$alt_bin" >/dev/null 2>&1; } \
937
+ || { [ -n "$fallback_dir" ] && [ -x "$fallback_dir/$bin" ]; }; then
938
+ ok "$id language server already installed ($bin)"
939
+ continue
940
+ fi
941
+ if ! command -v "$toolchain" >/dev/null 2>&1; then
942
+ warn "--lsp $id: needs '$toolchain' on PATH (install command: $cmd). Skipping — re-run after installing the $toolchain toolchain."
943
+ continue
944
+ fi
945
+ # shellcheck disable=SC2086 — $cmd is a fixed argv from the case above, never user input.
946
+ if spin "Installing $id language server" $cmd; then
947
+ ok "$id language server installed ($cmd)"
948
+ if [ -n "$fallback_dir" ] && ! command -v "$bin" >/dev/null 2>&1; then
949
+ info "$bin landed in $fallback_dir (not on PATH). The pi lsp tool finds it there; other tools may need a PATH entry."
950
+ fi
951
+ else
952
+ warn "--lsp $id: '$cmd' failed. Run it manually, or ask pi to enable $id LSP support later."
953
+ fi
954
+ done
955
+ }
956
+
846
957
  # resolve_user_name mirrors resolve_user_email but for display name. Priority:
847
958
  # WEAVE_USER_NAME env override → git config user.name → empty. We don't
848
959
  # prompt for name independently: if email prompting yielded nothing, name
@@ -926,10 +1037,12 @@ while [ $# -gt 0 ]; do
926
1037
  --base-url)
927
1038
  base_url="${2:-}"; shift 2
928
1039
  [ -n "$base_url" ] || { err "--base-url requires a value."; exit 2; }
1040
+ base_url_explicit="true"
929
1041
  ;;
930
1042
  --local)
931
1043
  # Shorthand for local dev: localhost:8080 (matches `wv mr` / `make dev` default PORT).
932
1044
  base_url="http://localhost:8080"
1045
+ base_url_explicit="true"
933
1046
  shift
934
1047
  ;;
935
1048
  --non-interactive)
@@ -938,6 +1051,9 @@ while [ $# -gt 0 ]; do
938
1051
  --quiet)
939
1052
  quiet="true"; shift
940
1053
  ;;
1054
+ --rotate-key)
1055
+ rotate_key="true"; shift
1056
+ ;;
941
1057
  --dir)
942
1058
  install_dir="${2:-}"; shift 2
943
1059
  [ -n "$install_dir" ] || { err "--dir requires a path."; exit 2; }
@@ -951,6 +1067,10 @@ while [ $# -gt 0 ]; do
951
1067
  --pi)
952
1068
  target="pi"; target_explicit="true"; shift
953
1069
  ;;
1070
+ --lsp)
1071
+ lsp_langs="${2:-}"; shift 2
1072
+ [ -n "$lsp_langs" ] || { err "--lsp requires a comma-separated language list (go,typescript,python,rust)."; exit 2; }
1073
+ ;;
954
1074
  --claude)
955
1075
  # No-op selector for symmetry with --codex / --opencode. Useful in
956
1076
  # pipelines that want to skip the interactive picker without depending
@@ -962,21 +1082,55 @@ while [ $# -gt 0 ]; do
962
1082
  # npm wrapper forwards argv verbatim so either form reaches us.
963
1083
  mode="${1#--}"; shift
964
1084
  ;;
1085
+ update|--update)
1086
+ # Non-interactive refresh of an existing install. Takes the same target
1087
+ # and scope flags as install; resolves the key from env or disk only.
1088
+ mode="update"; shift
1089
+ ;;
965
1090
  disable-routing|--disable-routing)
966
1091
  # Convenience alias for the Codex-specific off toggle. Unlike generic
967
1092
  # `off`, it deliberately resolves the target after all flags are parsed,
968
1093
  # so `disable-routing --claude` cannot silently change Claude settings.
969
1094
  mode="off"; disable_routing_alias="true"; shift
970
1095
  ;;
1096
+ models|--models)
1097
+ # Model selection. Sub-verb and operand collection happens in the
1098
+ # catch-all arm below (guarded on mode="models"), not here — that lets
1099
+ # `--claude`/`--json`/etc. appear before, after, or between operands
1100
+ # instead of only after every operand has been consumed.
1101
+ mode="models"; shift
1102
+ ;;
1103
+ --json)
1104
+ models_json="true"; shift
1105
+ ;;
971
1106
  -h|--help)
972
1107
  usage 0
973
1108
  ;;
974
1109
  *)
975
- err "Unknown flag: $1."; usage 2
1110
+ # In `models` mode, a bare (non-dashed) word is a sub-verb or operand —
1111
+ # collect it and keep parsing, so a flag can appear before, after, or
1112
+ # between them (`models --claude enable x` and `models enable x --claude`
1113
+ # both work). A dashed token nothing above matched is still an error,
1114
+ # models mode or not.
1115
+ if [ "$mode" = "models" ] && [ "${1#-}" = "$1" ]; then
1116
+ models_args="${models_args}${models_args:+$'\n'}$1"; shift
1117
+ else
1118
+ err "Unknown flag: $1."; usage 2
1119
+ fi
976
1120
  ;;
977
1121
  esac
978
1122
  done
979
1123
 
1124
+ # --lsp is a pi-extension feature, so it implies the pi target; combining it
1125
+ # with another explicit target is a contradiction, not a preference.
1126
+ if [ -n "$lsp_langs" ]; then
1127
+ if [ "$target_explicit" = "true" ] && [ "$target" != "pi" ]; then
1128
+ err "--lsp only applies to the pi target (the lsp tool ships in the pi extension). Drop --lsp or use --pi."
1129
+ exit 2
1130
+ fi
1131
+ target="pi"; target_explicit="true"
1132
+ fi
1133
+
980
1134
  if [ "$disable_routing_alias" = "true" ]; then
981
1135
  if [ "$target_explicit" = "true" ] && [ "$target" != "codex" ]; then
982
1136
  err "disable-routing only applies to Codex; omit the client flag or use --codex."
@@ -988,11 +1142,51 @@ fi
988
1142
 
989
1143
  # Toggle verbs only flip config install.sh already wrote: no key, no identity,
990
1144
  # no prompts. Require an explicit client so we never guess which config to
991
- # touch, and suppress every interactive prompt downstream.
992
- if [ "$mode" != "install" ]; then
1145
+ # touch, and suppress every interactive prompt downstream. `models` edits no
1146
+ # local config, but it still reads the endpoint and key out of one install, so
1147
+ # it needs the same explicit choice.
1148
+ if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
993
1149
  non_interactive="true"
994
1150
  if [ "$target_explicit" != "true" ]; then
995
- err "'$mode' requires an explicit client: --claude, --codex, or --opencode."
1151
+ if [ "$mode" = "models" ]; then
1152
+ err "'models' requires an explicit client: --claude."
1153
+ else
1154
+ err "'$mode' requires an explicit client: --claude, --codex, or --opencode."
1155
+ fi
1156
+ exit 2
1157
+ fi
1158
+ fi
1159
+
1160
+ # Only Claude Code's settings are read back for a key today (read_installed_key),
1161
+ # so `models` can't authenticate as any other client. Fail fast here rather than
1162
+ # falling through to the toggle dispatch, which would flip config nobody asked
1163
+ # to change.
1164
+ if [ "$mode" = "models" ] && [ "$target" != "claude" ]; then
1165
+ err "'models' currently supports --claude only (it reads the router key from Claude Code's settings)."
1166
+ exit 2
1167
+ fi
1168
+
1169
+ # --json prints the API payload verbatim, so nothing else may reach stdout.
1170
+ if [ "$models_json" = "true" ]; then
1171
+ if [ "$mode" != "models" ]; then
1172
+ err "--json only applies to 'models'."
1173
+ exit 2
1174
+ fi
1175
+ quiet="true"
1176
+ fi
1177
+
1178
+ # `update` is an install that never prompts: it refreshes the managed config
1179
+ # and assets using the key already on disk (or in the environment). Suppressing
1180
+ # prompts here also settles the target/scope pickers below on their defaults,
1181
+ # which is what a cron/script caller wants.
1182
+ if [ "$mode" = "update" ]; then
1183
+ non_interactive="true"
1184
+ if [ "$rotate_key" = "true" ]; then
1185
+ err "--rotate-key needs a prompt, which 'update' never issues. Re-run the installer without 'update'."
1186
+ exit 2
1187
+ fi
1188
+ if [ "$target" != "claude" ]; then
1189
+ err "'update' currently supports --claude only. Re-run the installer for $target (it reuses your installed key)."
996
1190
  exit 2
997
1191
  fi
998
1192
  fi
@@ -1114,9 +1308,9 @@ fi
1114
1308
 
1115
1309
  # ---------- pre-flight ----------
1116
1310
 
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
1311
+ # `models` skips this line: it prints its own header carrying the endpoint it
1312
+ # resolved, which is the part a reader actually needs.
1313
+ if [ "$mode" != "install" ] && [ "$mode" != "update" ] && [ "$mode" != "models" ]; then
1120
1314
  [ "$quiet" = "true" ] || info "mode=${C_BOLD}${mode}${C_RESET} scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET}"
1121
1315
  fi
1122
1316
 
@@ -1127,10 +1321,17 @@ fi
1127
1321
  if [ "$target" = "claude" ] || [ "$target" = "opencode" ] || [ "$target" = "pi" ]; then
1128
1322
  require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
1129
1323
  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"
1324
+ # curl is used by the install/update paths' health/validate probes and by every
1325
+ # `models` call; the on/off/status toggles never hit the network.
1326
+ if [ "$mode" = "install" ] || [ "$mode" = "update" ] || [ "$mode" = "models" ]; then
1327
+ require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
1328
+ fi
1133
1329
 
1330
+ # Every warning below says the client's config will be written anyway, which is
1331
+ # true for install/update/toggles and false for `models` — it only reads that
1332
+ # config to find the router. Whether the client itself is installed has no
1333
+ # bearing on a model-selection call, so skip the check there entirely.
1334
+ if [ "$mode" != "models" ]; then
1134
1335
  case "$target" in
1135
1336
  claude)
1136
1337
  if ! command -v claude >/dev/null 2>&1; then
@@ -1157,9 +1358,118 @@ case "$target" in
1157
1358
  fi
1158
1359
  ;;
1159
1360
  esac
1361
+ fi
1160
1362
 
1161
1363
  script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
1162
1364
 
1365
+ # ---------- directive registry (embedded) ----------
1366
+ #
1367
+ # install.sh is served standalone (WorkWeave serves it for `curl | sh`), so it
1368
+ # cannot source install/registry.sh or read install/directives.tsv. Both are
1369
+ # embedded here verbatim; install/tests/registry_test.sh asserts they never
1370
+ # drift from the canonical files.
1371
+ WEAVE_REGISTRY_DATA=$(cat <<'WEAVE_REGISTRY_EOF'
1372
+ # Weave Router directive registry
1373
+ # canonical|aliases|capability|claude|codex|opencode|pi|cursor|adapter
1374
+ # aliases are comma-separated; client columns are yes/no. adapter is the native asset kind.
1375
+ force-model|fm|prompt|yes|yes|yes|yes|manual|command,skill
1376
+ unforce-model|ufm|prompt|yes|yes|yes|yes|manual|command,skill
1377
+ router-feedback|rf|prompt|yes|yes|yes|no|manual|command,skill
1378
+ router-off||local-toggle|yes|no|no|no|manual|command
1379
+ router-on||local-toggle|yes|no|no|no|manual|command
1380
+ router-status||local-toggle|yes|no|no|no|manual|command
1381
+ router-session||prompt|yes|no|no|no|manual|command
1382
+ router-models|models|local-toggle|yes|no|no|no|manual|command
1383
+ disable-routing||local-toggle|no|yes|no|no|manual|skill
1384
+ WEAVE_REGISTRY_EOF
1385
+ )
1386
+
1387
+ # >>> weave-router registry lib >>>
1388
+ weave_registry_rows() {
1389
+ if [ -n "${WEAVE_REGISTRY_DATA:-}" ]; then
1390
+ printf '%s\n' "$WEAVE_REGISTRY_DATA" | awk -F '|' '!/^([[:space:]]*#|[[:space:]]*$)/ { print }'
1391
+ return 0
1392
+ fi
1393
+ local registry="${WEAVE_REGISTRY_FILE:-}"
1394
+ if [ -z "$registry" ]; then
1395
+ local dir
1396
+ dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)" || return 1
1397
+ registry="$dir/directives.tsv"
1398
+ fi
1399
+ [ -f "$registry" ] || return 1
1400
+ awk -F '|' '!/^([[:space:]]*#|[[:space:]]*$)/ { print }' "$registry"
1401
+ }
1402
+
1403
+ # weave_registry_names lists every canonical name and alias a client installs.
1404
+ weave_registry_names() {
1405
+ local target="$1" canonical aliases capability claude codex opencode pi cursor adapter
1406
+ while IFS='|' read -r canonical aliases capability claude codex opencode pi cursor adapter; do
1407
+ case "$target" in
1408
+ claude) [ "$claude" = yes ] || continue ;; codex) [ "$codex" = yes ] || continue ;;
1409
+ opencode) [ "$opencode" = yes ] || continue ;; pi) [ "$pi" = yes ] || continue ;;
1410
+ cursor) [ "$cursor" = yes ] || continue ;;
1411
+ esac
1412
+ printf '%s\n' "$canonical"
1413
+ [ -n "$aliases" ] && printf '%s\n' "$aliases" | tr ',' '\n'
1414
+ done <<EOF
1415
+ $(weave_registry_rows)
1416
+ EOF
1417
+ }
1418
+
1419
+ # weave_registry_skill_names lists the prompt directives a client exposes as a
1420
+ # native skill. Local-config toggles are excluded: they mutate config this
1421
+ # installer owns and are handled per target, not as a generic prompt.
1422
+ weave_registry_skill_names() {
1423
+ local target="$1" canonical aliases capability claude codex opencode pi cursor adapter
1424
+ while IFS='|' read -r canonical aliases capability claude codex opencode pi cursor adapter; do
1425
+ [ "$capability" = prompt ] || continue
1426
+ case "$target" in
1427
+ claude) [ "$claude" = yes ] || continue ;; codex) [ "$codex" = yes ] || continue ;;
1428
+ opencode) [ "$opencode" = yes ] || continue ;; pi) [ "$pi" = yes ] || continue ;;
1429
+ cursor) [ "$cursor" = yes ] || continue ;;
1430
+ esac
1431
+ printf '%s\n' "$canonical"
1432
+ done <<EOF
1433
+ $(weave_registry_rows)
1434
+ EOF
1435
+ }
1436
+
1437
+ # weave_registry_skill_assets lists every directive a client ships as a skill
1438
+ # file, including local-config toggles such as Codex's disable-routing. Install
1439
+ # and uninstall use this for file management; weave_registry_skill_names is the
1440
+ # narrower prompt-only set used when generating prompt adapters.
1441
+ weave_registry_skill_assets() {
1442
+ local target="$1" canonical aliases capability claude codex opencode pi cursor adapter
1443
+ while IFS='|' read -r canonical aliases capability claude codex opencode pi cursor adapter; do
1444
+ case ",$adapter," in *,skill,*) ;; *) continue ;; esac
1445
+ case "$target" in
1446
+ claude) [ "$claude" = yes ] || continue ;; codex) [ "$codex" = yes ] || continue ;;
1447
+ opencode) [ "$opencode" = yes ] || continue ;; pi) [ "$pi" = yes ] || continue ;;
1448
+ cursor) [ "$cursor" = yes ] || continue ;;
1449
+ esac
1450
+ printf '%s\n' "$canonical"
1451
+ done <<EOF
1452
+ $(weave_registry_rows)
1453
+ EOF
1454
+ }
1455
+
1456
+ # weave_registry_canonical_for resolves a name or alias to its canonical
1457
+ # directive, and fails when the name is not in the registry at all.
1458
+ weave_registry_canonical_for() {
1459
+ local wanted="$1" canonical aliases capability claude codex opencode pi cursor adapter alias
1460
+ while IFS='|' read -r canonical aliases capability claude codex opencode pi cursor adapter; do
1461
+ [ "$wanted" = "$canonical" ] && { printf '%s' "$canonical"; return 0; }
1462
+ IFS=',' read -ra _aliases <<< "$aliases"
1463
+ for alias in ${_aliases[@]+"${_aliases[@]}"}; do
1464
+ [ "$wanted" = "$alias" ] && { printf '%s' "$canonical"; return 0; }
1465
+ done
1466
+ done <<EOF
1467
+ $(weave_registry_rows)
1468
+ EOF
1469
+ return 1
1470
+ }
1471
+ # <<< weave-router registry lib <<<
1472
+
1163
1473
  # Resolve the base directory. User scope always uses $HOME. Project scope uses
1164
1474
  # --dir if given, otherwise the CWD's git root. --dir alone (no --scope) is a
1165
1475
  # throwaway user-style install.
@@ -1331,6 +1641,19 @@ json_get() {
1331
1641
  jq -r "${2} // empty" "$1" 2>/dev/null || true
1332
1642
  }
1333
1643
 
1644
+ # read_claude_key prints the router key already installed in the given settings
1645
+ # file, or nothing when the file is absent / carries no key header. Claude Code
1646
+ # packs several headers into one newline-delimited ANTHROPIC_CUSTOM_HEADERS
1647
+ # value, so we pick the router-key line out of it and trim the header name.
1648
+ # Read-only and tolerant of every missing-file case — never trips set -e.
1649
+ read_claude_key() {
1650
+ [ -n "${1:-}" ] || return 0
1651
+ json_get "$1" '.env.ANTHROPIC_CUSTOM_HEADERS' \
1652
+ | sed -n "s/^[[:space:]]*${router_key_header}:[[:space:]]*//p" \
1653
+ | head -n 1 \
1654
+ | tr -d '[:space:]'
1655
+ }
1656
+
1334
1657
  # claude_key_present returns 0 when the given settings file's
1335
1658
  # env.ANTHROPIC_CUSTOM_HEADERS carries the router key header. "On" is only valid
1336
1659
  # when this is true: in project scope the committed settings.json holds the
@@ -1338,10 +1661,106 @@ json_get() {
1338
1661
  # (or the parked sidecar), so a router URL alone doesn't mean requests can
1339
1662
  # authenticate.
1340
1663
  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
1664
+ [ -n "$(read_claude_key "$1")" ]
1665
+ }
1666
+
1667
+ # read_installed_key prints the router key this install already has on disk, or
1668
+ # nothing. Only Claude Code is supported today; the other targets still prompt.
1669
+ # Defined up here rather than with the rest of token handling because `models`
1670
+ # dispatches (and needs a key) before that section runs.
1671
+ #
1672
+ # models_key_file_order echoes the precedence this uses, so a caller that needs
1673
+ # to know *which* file the key came from can walk the same list (a command
1674
+ # substitution around this function would discard any global it set).
1675
+ models_key_file_order() {
1676
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1677
+ printf '%s\n%s\n%s\n' "$local_settings_file" "$settings_file" "$settings_dir/.weave-parked.json"
1678
+ else
1679
+ printf '%s\n%s\n%s\n' "$settings_file" "$local_settings_file" "$settings_dir/.weave-parked.json"
1680
+ fi
1681
+ }
1682
+
1683
+ read_installed_key() {
1684
+ [ "$target" = "claude" ] || return 0
1685
+ local key="" candidate
1686
+ # Mirror where the install path writes the key: project scope (no --dir) puts
1687
+ # it in the gitignored settings.local.json, everything else inlines it into
1688
+ # settings.json. Check the other file too — a scope was possibly changed, or a
1689
+ # project checkout may carry a committed header from an older install.
1690
+ # The parked sidecar is last: `off` moves the key header out of the settings
1691
+ # files and into it, so a run while toggled off finds nothing above even
1692
+ # though the key is still on disk. Same {"env":{…}} shape, so read_claude_key
1693
+ # reads it directly.
1694
+ while IFS= read -r candidate; do
1695
+ [ -n "$candidate" ] || continue
1696
+ key="$(read_claude_key "$candidate")"
1697
+ [ -n "$key" ] && break
1698
+ done <<<"$(models_key_file_order)"
1699
+ printf '%s' "$key"
1700
+ }
1701
+
1702
+ # resolve_installed_base_url prints the router endpoint this Claude Code install
1703
+ # already points at, or nothing when none of its files carry a router-shaped
1704
+ # one. The parked sidecar comes first: `off` moves the router URL there and
1705
+ # leaves api.anthropic.com in the live file, so reading the live file while
1706
+ # toggled off would report Anthropic as the router.
1707
+ #
1708
+ # models_base_file_order echoes that same precedence for callers that need to
1709
+ # know which file supplied the endpoint (see models_key_file_order).
1710
+ models_base_file_order() {
1711
+ printf '%s\n%s\n%s\n' "$settings_dir/.weave-parked.json" "$settings_file" "$local_settings_file"
1712
+ }
1713
+
1714
+ resolve_installed_base_url() {
1715
+ local candidate found
1716
+ while IFS= read -r candidate; do
1717
+ [ -n "$candidate" ] || continue
1718
+ found="$(json_get "$candidate" '.env.ANTHROPIC_BASE_URL')"
1719
+ if router_shaped_url "$found"; then
1720
+ printf '%s' "${found%/}"
1721
+ return 0
1722
+ fi
1723
+ done <<<"$(models_base_file_order)"
1724
+ printf ''
1725
+ }
1726
+
1727
+ # models_endpoint_is_trusted returns 0 when it is safe to send the router key
1728
+ # resolved from $2 to the endpoint resolved from $1.
1729
+ #
1730
+ # Project scope deliberately splits the two: the endpoint lives in the
1731
+ # committed settings.json (teammates share it) while each teammate's key lives
1732
+ # in the gitignored settings.local.json. That split is fine on its own, but it
1733
+ # means a *hostile* repo can commit a settings.json naming an endpoint it
1734
+ # controls and have this command mail the developer's key to it. Endpoint and
1735
+ # credential from the same file are self-consistent and always fine. When they
1736
+ # differ, the endpoint must be one the user vouched for out-of-band — the
1737
+ # hosted default, or an explicit --base-url — rather than whatever the checkout
1738
+ # happened to contain.
1739
+ models_endpoint_is_trusted() {
1740
+ local url="$1" base_src="$2" key_src="$3"
1741
+ [ "$base_url_explicit" = "true" ] && return 0
1742
+ [ "$url" = "$HOSTED_BASE_URL" ] && return 0
1743
+ [ -n "$base_src" ] && [ "$base_src" = "$key_src" ] && return 0
1744
+ # Project-scoped self-hosted installs intentionally split the endpoint and
1745
+ # key. The installer writes a gitignored marker beside the key; require that
1746
+ # marker to match before trusting the split. A tracked/symlinked local file is
1747
+ # not a teammate's private configuration and cannot vouch for an endpoint.
1748
+ if [ "$key_src" = "$local_settings_file" ] && [ ! -L "$key_src" ]; then
1749
+ local marked_url
1750
+ marked_url="$(json_get "$key_src" '.env.WEAVE_ROUTER_BASE_URL')"
1751
+ if [ "${marked_url%/}" = "${url%/}" ]; then
1752
+ command -v git >/dev/null 2>&1 || return 1
1753
+ git -C "$(dirname "$key_src")" ls-files --error-unmatch -- "$key_src" >/dev/null 2>&1 && return 1
1754
+ return 0
1755
+ fi
1756
+ fi
1757
+ # A repo can only pre-plant a file git tracks; an untracked local file is the
1758
+ # user's own. If the endpoint's file isn't tracked, it wasn't planted. Checked
1759
+ # inline rather than via weave_command_tracked_by_git, which is defined further
1760
+ # down (inside the statusline heredoc's neighborhood) and isn't in scope here.
1761
+ command -v git >/dev/null 2>&1 || return 1
1762
+ git -C "$(dirname "$base_src")" ls-files --error-unmatch -- "$base_src" >/dev/null 2>&1 || return 0
1763
+ return 1
1345
1764
  }
1346
1765
 
1347
1766
  # gitignore_add appends an entry to the repo .gitignore in project scope so a
@@ -1646,7 +2065,394 @@ toggle_opencode() {
1646
2065
  esac
1647
2066
  }
1648
2067
 
1649
- if [ "$mode" != "install" ]; then
2068
+ # ---------- model selection ----------
2069
+ #
2070
+ # `models` reads and edits the installation's model/provider selection through
2071
+ # the router's /admin/v1 API — the same lists the router dashboard's settings
2072
+ # page renders, backed by the same columns. Nothing local is written: the
2073
+ # endpoint and router key are read out of the install already on disk so a
2074
+ # self-hosted install talks to its own router with its own key.
2075
+ #
2076
+ # The Weave-hosted router runs in `managed` mode and mounts no /admin/v1 at
2077
+ # all — there model selection belongs to the organization and is edited in the
2078
+ # Weave dashboard. That surfaces as a 404, which reads (for a list) as "fall
2079
+ # back to the public catalog and say where to edit" and (for an edit) as a
2080
+ # refusal naming the dashboard.
2081
+
2082
+ # Public, unauthed catalog of everything the router can route to. Mounted in
2083
+ # both deployment modes, so it is the one listing that always works.
2084
+ MODELS_CATALOG_PATH="/v1/router/models"
2085
+ MODELS_DASHBOARD_URL="https://router.workweave.ai/dashboard/settings"
2086
+
2087
+ # Set by models_api on every call.
2088
+ models_http_status=""
2089
+ models_http_body=""
2090
+
2091
+ # models_api METHOD PATH [JSON_BODY] — call the router and capture status+body.
2092
+ # The key rides in on stdin (curl --header @-) rather than a -H argument so it
2093
+ # never appears in the process arg list, which any other local user can read.
2094
+ models_api() {
2095
+ local method="$1" path="$2" body="${3:-}"
2096
+ local out status=""
2097
+ out="$(mktemp)" || { err "Could not create a temp file."; exit 1; }
2098
+ if [ -n "$body" ]; then
2099
+ status="$(printf '%s: %s\n' "$router_key_header" "$api_key" \
2100
+ | curl -sS --max-time 20 -X "$method" \
2101
+ -H 'Content-Type: application/json' --data-binary "$body" \
2102
+ --header @- -o "$out" -w '%{http_code}' "$base_url$path" 2>/dev/null)" || status=""
2103
+ else
2104
+ status="$(printf '%s: %s\n' "$router_key_header" "$api_key" \
2105
+ | curl -sS --max-time 20 -X "$method" \
2106
+ --header @- -o "$out" -w '%{http_code}' "$base_url$path" 2>/dev/null)" || status=""
2107
+ fi
2108
+ models_http_status="$status"
2109
+ models_http_body="$(cat "$out" 2>/dev/null || true)"
2110
+ rm -f "$out"
2111
+ case "$models_http_status" in
2112
+ 2*) return 0 ;;
2113
+ *) return 1 ;;
2114
+ esac
2115
+ }
2116
+
2117
+ # models_api_error prints the router's own `error` field, when it sent one.
2118
+ models_api_error() {
2119
+ printf '%s' "$models_http_body" | jq -r '.error // empty' 2>/dev/null || true
2120
+ }
2121
+
2122
+ # models_fail turns the status left by models_api into a message that says what
2123
+ # to do next, then exits. $1 names the attempted operation.
2124
+ models_fail() {
2125
+ local what="$1" detail
2126
+ detail="$(models_api_error)"
2127
+ case "$models_http_status" in
2128
+ ""|000)
2129
+ err "Could not reach the router at $base_url. Is it running?"
2130
+ ;;
2131
+ 401|403)
2132
+ # 403 with a message is the ROUTER_EXCLUDED_MODELS/PROVIDERS env pin,
2133
+ # which is actionable on its own; a bare 401/403 is a key problem.
2134
+ if [ -n "$detail" ]; then
2135
+ err "$detail"
2136
+ else
2137
+ err "The router rejected this installation's key. Re-run 'npx @workweave/router --claude --rotate-key' to install a current one."
2138
+ fi
2139
+ ;;
2140
+ 404)
2141
+ err "This router does not expose the model-selection API, so $what is not available here."
2142
+ printf " %sWeave-hosted routers keep model selection with the organization — edit it at %s%s\n" \
2143
+ "$C_DIM" "$MODELS_DASHBOARD_URL" "$C_RESET" >&2
2144
+ printf " %sSelf-hosted? Update the router to a build that serves /admin/v1/models.%s\n" \
2145
+ "$C_DIM" "$C_RESET" >&2
2146
+ ;;
2147
+ *)
2148
+ if [ -n "$detail" ]; then
2149
+ err "$what failed (HTTP $models_http_status): $detail"
2150
+ else
2151
+ err "$what failed (HTTP $models_http_status)."
2152
+ fi
2153
+ ;;
2154
+ esac
2155
+ exit 1
2156
+ }
2157
+
2158
+ # models_render_list prints the [{model,provider,enabled}] payload as a
2159
+ # provider-grouped checklist. The API sorts by provider then model, which is
2160
+ # what group_by needs, and what keeps two runs comparable.
2161
+ models_render_list() {
2162
+ local payload="$1" total enabled
2163
+ total="$(printf '%s' "$payload" | jq 'length')"
2164
+ enabled="$(printf '%s' "$payload" | jq '[.[] | select(.enabled)] | length')"
2165
+ printf "%s%sWeave Router models%s %s· %s%s\n" "$C_BOLD" "$C_BRAND" "$C_RESET" "$C_DIM" "$base_url" "$C_RESET"
2166
+ printf "%s%s of %s enabled%s\n\n" "$C_DIM" "$enabled" "$total" "$C_RESET"
2167
+ printf '%s' "$payload" | jq -r --arg on "$C_GREEN" --arg off "$C_DIM" --arg reset "$C_RESET" --arg bold "$C_BOLD" '
2168
+ group_by(.provider)[]
2169
+ | ($bold + .[0].provider + $reset),
2170
+ (.[] | if .enabled
2171
+ then " " + $on + "[x]" + $reset + " " + .model
2172
+ else " " + $off + "[ ] " + .model + $reset
2173
+ end)
2174
+ '
2175
+ }
2176
+
2177
+ # models_render_providers prints the [{provider,enabled}] payload.
2178
+ models_render_providers() {
2179
+ local payload="$1"
2180
+ printf "%s%sWeave Router providers%s %s· %s%s\n\n" "$C_BOLD" "$C_BRAND" "$C_RESET" "$C_DIM" "$base_url" "$C_RESET"
2181
+ printf '%s' "$payload" | jq -r --arg on "$C_GREEN" --arg off "$C_DIM" --arg reset "$C_RESET" '
2182
+ .[] | if .enabled
2183
+ then " " + $on + "[x]" + $reset + " " + .provider
2184
+ else " " + $off + "[ ] " + .provider + $reset
2185
+ end
2186
+ '
2187
+ }
2188
+
2189
+ # models_print_preferred appends the priority ranking when one is set. Absent
2190
+ # is the norm (the router picks per turn), so silence is the right output.
2191
+ models_print_preferred() {
2192
+ models_api GET "/admin/v1/preferred-models" || return 0
2193
+ local list
2194
+ list="$(printf '%s' "$models_http_body" | jq -r '(.preferred // []) | join(" > ")' 2>/dev/null || true)"
2195
+ [ -n "$list" ] || return 0
2196
+ printf "\n%sPreferred order:%s %s\n" "$C_DIM" "$C_RESET" "$list"
2197
+ }
2198
+
2199
+ # models_list_catalog is the read-only fallback for a router with no
2200
+ # model-selection API: list what it can route to and say where to change it.
2201
+ # Deliberately not rendered as a checklist — this endpoint reports the deployed
2202
+ # catalog, not the installation's selection, so marking every row [x] would
2203
+ # claim models are enabled that the dashboard may well have excluded.
2204
+ #
2205
+ # $1 selects what to print from the same catalog payload: "models" (the
2206
+ # default) or "providers" — `models providers` hits this same 404 and must
2207
+ # degrade the same way `models list` does rather than hard-failing, since both
2208
+ # are read-only listing commands.
2209
+ models_list_catalog() {
2210
+ local what="${1:-models}"
2211
+ models_api GET "$MODELS_CATALOG_PATH" || models_fail "listing $what"
2212
+ if [ "$models_json" = "true" ]; then
2213
+ if [ "$what" = "providers" ]; then
2214
+ printf '%s' "$models_http_body" | jq -c '[.models[].provider] | unique'
2215
+ else
2216
+ printf '%s\n' "$models_http_body"
2217
+ fi
2218
+ return 0
2219
+ fi
2220
+ printf "%s%sWeave Router %s%s %s· %s%s\n" "$C_BOLD" "$C_BRAND" "$what" "$C_RESET" "$C_DIM" "$base_url" "$C_RESET"
2221
+ printf "%severything this router can route to%s\n\n" "$C_DIM" "$C_RESET"
2222
+ if [ "$what" = "providers" ]; then
2223
+ printf '%s' "$models_http_body" | jq -r '[.models[].provider] | unique[] | " " + .'
2224
+ else
2225
+ printf '%s' "$models_http_body" | jq -r --arg reset "$C_RESET" --arg bold "$C_BOLD" '
2226
+ .models | group_by(.provider)[]
2227
+ | ($bold + .[0].provider + $reset),
2228
+ (.[] | " " + .model)
2229
+ '
2230
+ fi
2231
+ printf "\n%sThis router does not report which of them your installation has enabled:%s\n" "$C_DIM" "$C_RESET"
2232
+ printf "%sselection is an organization-wide setting here. See it, and change it, at%s\n" "$C_DIM" "$C_RESET"
2233
+ printf "%s%s%s\n" "$C_DIM" "$MODELS_DASHBOARD_URL" "$C_RESET"
2234
+ }
2235
+
2236
+ models_list() {
2237
+ if ! models_api GET "/admin/v1/models"; then
2238
+ # Only a missing API is worth degrading for; anything else is a real error.
2239
+ [ "$models_http_status" = "404" ] || models_fail "listing models"
2240
+ models_list_catalog models
2241
+ return 0
2242
+ fi
2243
+ if [ "$models_json" = "true" ]; then
2244
+ printf '%s\n' "$models_http_body"
2245
+ return 0
2246
+ fi
2247
+ models_render_list "$models_http_body"
2248
+ models_print_preferred
2249
+ printf "\n%sEnable a model:%s npx @workweave/router models enable <id> --claude\n" "$C_DIM" "$C_RESET"
2250
+ printf "%sDisable a model:%s npx @workweave/router models disable <id> --claude\n" "$C_DIM" "$C_RESET"
2251
+ }
2252
+
2253
+ models_providers_list() {
2254
+ if ! models_api GET "/admin/v1/providers"; then
2255
+ # Same 404 degrade as models_list: a router with no model-selection API
2256
+ # still answers the unauthed catalog, and this is a read-only listing
2257
+ # command same as `models list` — no reason to hard-fail one and not
2258
+ # the other.
2259
+ [ "$models_http_status" = "404" ] || models_fail "listing providers"
2260
+ models_list_catalog providers
2261
+ return 0
2262
+ fi
2263
+ if [ "$models_json" = "true" ]; then
2264
+ printf '%s\n' "$models_http_body"
2265
+ return 0
2266
+ fi
2267
+ models_render_providers "$models_http_body"
2268
+ }
2269
+
2270
+ # models_toggle KIND ACTION IDS — flip one or more models/providers on or off.
2271
+ # Each id is its own request so a typo in the third id can't roll back the two
2272
+ # that already applied, and so the report names exactly what changed.
2273
+ models_toggle() {
2274
+ local kind="$1" action="$2" ids="$3"
2275
+ local path body id label doing
2276
+ case "$kind" in
2277
+ model) path="/admin/v1/excluded-models" ; label="model" ;;
2278
+ provider) path="/admin/v1/excluded-providers" ; label="provider" ;;
2279
+ esac
2280
+ # Enabling means dropping the id from the exclusion list, disabling means
2281
+ # adding it — the API's remove/add endpoints, inverted here so the CLI reads
2282
+ # the way the dashboard's checkboxes do.
2283
+ doing="disabling"
2284
+ if [ "$action" = "enable" ]; then
2285
+ doing="enabling"
2286
+ path="$path/remove"
2287
+ fi
2288
+
2289
+ # Fed by a here-string, not a pipe: a pipe would run the loop in a subshell
2290
+ # where models_fail's exit only kills that subshell.
2291
+ while IFS= read -r id; do
2292
+ [ -n "$id" ] || continue
2293
+ body="$(jq -nc --arg v "$id" --arg k "$label" '{($k): $v}')"
2294
+ if ! models_api POST "$path" "$body"; then
2295
+ models_fail "$doing $label '$id'"
2296
+ fi
2297
+ # Under --json the router's own response body is the output: a scripted
2298
+ # caller parses stdout, and printing prose there makes it report a parse
2299
+ # failure for a mutation that already succeeded.
2300
+ if [ "$models_json" = "true" ]; then
2301
+ printf '%s\n' "$models_http_body"
2302
+ elif [ "$action" = "enable" ]; then
2303
+ printf "%s✓%s %s %s%s%s is enabled\n" "$C_GREEN" "$C_RESET" "$label" "$C_BOLD" "$id" "$C_RESET"
2304
+ else
2305
+ printf "%s✓%s %s %s%s%s is disabled — the router will not pick it\n" "$C_GREEN" "$C_RESET" "$label" "$C_BOLD" "$id" "$C_RESET"
2306
+ fi
2307
+ done <<<"$ids"
2308
+ }
2309
+
2310
+ # models_prefer replaces the priority ranking outright (the API's PUT), because
2311
+ # a ranking is an ordering: appending one id at a time can't express "this one
2312
+ # first". `clear` drops it and returns the installation to plain routing.
2313
+ models_prefer() {
2314
+ local ids="$1" body stored
2315
+ if [ "$ids" = "clear" ] || [ "$ids" = "none" ]; then
2316
+ body='{"preferred":[]}'
2317
+ else
2318
+ body="$(printf '%s' "$ids" | jq -c -R -s '{preferred: (split("\n") | map(select(length > 0)))}')"
2319
+ fi
2320
+ models_api PUT "/admin/v1/preferred-models" "$body" || models_fail "setting the preferred-model ranking"
2321
+ if [ "$models_json" = "true" ]; then
2322
+ printf '%s\n' "$models_http_body"
2323
+ return 0
2324
+ fi
2325
+ stored="$(printf '%s' "$models_http_body" | jq -r '(.preferred // []) | join(" > ")')"
2326
+ if [ -n "$stored" ]; then
2327
+ printf "%s✓%s Preferred order: %s\n" "$C_GREEN" "$C_RESET" "$stored"
2328
+ else
2329
+ printf "%s✓%s Preferred-model ranking cleared.\n" "$C_GREEN" "$C_RESET"
2330
+ fi
2331
+ }
2332
+
2333
+ models_usage() {
2334
+ err "$1"
2335
+ printf '%s\n' \
2336
+ " npx @workweave/router models --claude # list models" \
2337
+ " npx @workweave/router models enable <id> [<id>…] --claude" \
2338
+ " npx @workweave/router models disable <id> [<id>…] --claude" \
2339
+ " npx @workweave/router models providers --claude # list providers" \
2340
+ " npx @workweave/router models providers disable <name> --claude" \
2341
+ " npx @workweave/router models prefer <id> [<id>…] --claude # ranking ('clear' to drop)" >&2
2342
+ exit 2
2343
+ }
2344
+
2345
+ run_models() {
2346
+ local verb operands
2347
+ verb="$(printf '%s' "$models_args" | head -n 1)"
2348
+ operands="$(printf '%s' "$models_args" | tail -n +2)"
2349
+
2350
+ # `models providers …` shifts one more word: the sub-verb is the second word.
2351
+ case "$verb" in
2352
+ ""|list)
2353
+ [ -z "$operands" ] || models_usage "'models $verb' takes no arguments."
2354
+ models_list
2355
+ ;;
2356
+ enable|disable)
2357
+ [ -n "$operands" ] || models_usage "'models $verb' needs at least one model id."
2358
+ models_toggle model "$verb" "$operands"
2359
+ ;;
2360
+ prefer)
2361
+ [ -n "$operands" ] || models_usage "'models prefer' needs model ids, or 'clear'."
2362
+ models_prefer "$operands"
2363
+ ;;
2364
+ providers)
2365
+ local sub rest
2366
+ sub="$(printf '%s' "$operands" | head -n 1)"
2367
+ rest="$(printf '%s' "$operands" | tail -n +2)"
2368
+ case "$sub" in
2369
+ ""|list)
2370
+ models_providers_list
2371
+ ;;
2372
+ enable|disable)
2373
+ [ -n "$rest" ] || models_usage "'models providers $sub' needs at least one provider name."
2374
+ models_toggle provider "$sub" "$rest"
2375
+ ;;
2376
+ *)
2377
+ models_usage "Unknown providers sub-command: '$sub'."
2378
+ ;;
2379
+ esac
2380
+ ;;
2381
+ *)
2382
+ models_usage "Unknown models sub-command: '$verb'."
2383
+ ;;
2384
+ esac
2385
+ }
2386
+
2387
+ if [ "$mode" = "models" ]; then
2388
+ # Which file supplied the endpoint / the key. Empty means "not from a settings
2389
+ # file" — an explicit --base-url, or WEAVE_ROUTER_KEY. Initialized before the
2390
+ # branches below so `set -u` holds on every path.
2391
+ models_base_source=""
2392
+ models_key_source=""
2393
+ # Editing model selection needs the endpoint and key of one specific install,
2394
+ # never the hosted defaults: a self-hosted user pointing at their own router
2395
+ # would otherwise silently edit the hosted one's installation.
2396
+ if [ "$base_url_explicit" != "true" ]; then
2397
+ models_base="$(resolve_installed_base_url)"
2398
+ if [ -z "$models_base" ]; then
2399
+ err "No Weave Router install found for Claude Code in this scope. Run 'npx @workweave/router --claude' first, or pass --base-url."
2400
+ exit 1
2401
+ fi
2402
+ base_url="$models_base"
2403
+ # resolve_installed_base_url ran in a command substitution, so any global it
2404
+ # set died with that subshell. Recover the source by walking the same
2405
+ # precedence to find which file holds the endpoint we just adopted.
2406
+ while IFS= read -r models_src_candidate; do
2407
+ [ -n "$models_src_candidate" ] || continue
2408
+ models_src_url="$(json_get "$models_src_candidate" '.env.ANTHROPIC_BASE_URL')"
2409
+ if [ "${models_src_url%/}" = "$models_base" ]; then
2410
+ models_base_source="$models_src_candidate"
2411
+ break
2412
+ fi
2413
+ done <<<"$(models_base_file_order)"
2414
+ fi
2415
+ # WEAVE_ROUTER_KEY is the user's own choice, so it pairs with any endpoint.
2416
+ if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
2417
+ api_key="$WEAVE_ROUTER_KEY"
2418
+ models_key_source="env:WEAVE_ROUTER_KEY"
2419
+ else
2420
+ api_key="$(read_installed_key)"
2421
+ # Same subshell caveat as the endpoint above: recover which file the key
2422
+ # came from by re-reading them in read_installed_key's own precedence.
2423
+ models_key_source=""
2424
+ while IFS= read -r models_src_candidate; do
2425
+ [ -n "$models_src_candidate" ] || continue
2426
+ if [ -n "$(read_claude_key "$models_src_candidate")" ]; then
2427
+ models_key_source="$models_src_candidate"
2428
+ break
2429
+ fi
2430
+ done <<<"$(models_key_file_order)"
2431
+ fi
2432
+ if [ -z "$api_key" ]; then
2433
+ err "No router key found for Claude Code in this scope. Re-run 'npx @workweave/router --claude', or export WEAVE_ROUTER_KEY."
2434
+ exit 1
2435
+ fi
2436
+ # Never send a key to an endpoint the checkout supplied. See
2437
+ # models_endpoint_is_trusted: a hostile repo can commit a settings.json naming
2438
+ # its own router, and pairing that with the teammate key from the gitignored
2439
+ # settings.local.json would hand the key to whoever wrote the repo.
2440
+ if [ "$models_key_source" != "env:WEAVE_ROUTER_KEY" ] \
2441
+ && ! models_endpoint_is_trusted "$base_url" "$models_base_source" "$models_key_source"; then
2442
+ err "Refusing to send this installation's router key to $base_url."
2443
+ printf " %sThat endpoint comes from %s, which git tracks — a checked-out repo can set it —%s\n" \
2444
+ "$C_DIM" "${models_base_source##*/}" "$C_RESET" >&2
2445
+ printf " %swhile the key comes from %s. Pass --base-url <url> to confirm the endpoint,%s\n" \
2446
+ "$C_DIM" "${models_key_source##*/}" "$C_RESET" >&2
2447
+ printf " %sor re-run 'npx @workweave/router --claude' to install against the one you want.%s\n" \
2448
+ "$C_DIM" "$C_RESET" >&2
2449
+ exit 1
2450
+ fi
2451
+ run_models
2452
+ exit 0
2453
+ fi
2454
+
2455
+ if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
1650
2456
  case "$target" in
1651
2457
  claude) toggle_claude ;;
1652
2458
  codex) toggle_codex ;;
@@ -1655,35 +2461,89 @@ if [ "$mode" != "install" ]; then
1655
2461
  exit 0
1656
2462
  fi
1657
2463
 
2464
+ # ---------- endpoint carry-over for update ----------
2465
+ #
2466
+ # `update` refreshes an install in place, so it must not silently retarget a
2467
+ # self-hosted or custom endpoint at the hosted default just because --base-url
2468
+ # wasn't repeated. Read the endpoint already configured and keep it; an explicit
2469
+ # --base-url / --local still wins, and install mode is untouched (it is how you
2470
+ # deliberately move an install to a new endpoint).
2471
+ #
2472
+ # `off` parks the router URL and points Claude Code at Anthropic, so the parked
2473
+ # sidecar is the authority while toggled off — reading the live file there would
2474
+ # pin the install to api.anthropic.com.
2475
+ if [ "$mode" = "update" ] && [ "$base_url_explicit" != "true" ] && [ "$target" = "claude" ]; then
2476
+ installed_base="$(resolve_installed_base_url)"
2477
+ if [ -n "$installed_base" ]; then
2478
+ base_url="${installed_base%/}"
2479
+ fi
2480
+ fi
2481
+
2482
+ if [ "$mode" = "install" ]; then
2483
+ [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
2484
+ else
2485
+ [ "$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}"
2486
+ fi
2487
+
1658
2488
  # ---------- token handling ----------
2489
+ #
2490
+ # Resolution order: WEAVE_ROUTER_KEY, then the key this install already wrote to
2491
+ # disk, then an interactive prompt. Reading the installed key back is what makes
2492
+ # a re-run painless — the installer refreshes assets and config shape often
2493
+ # enough that users re-run it routinely, and re-pasting a key every time is pure
2494
+ # friction. --rotate-key skips the read-back so a new key can replace the old.
2495
+ # read_installed_key itself is defined above, next to the other settings readers.
2496
+
2497
+ # prompt_for_key reads a key from the controlling terminal into $api_key. Only
2498
+ # ever called on an interactive install; callers check first.
2499
+ prompt_for_key() {
2500
+ # Read from /dev/tty explicitly so the prompt works under `curl -fsSL ... | sh`,
2501
+ # where stdin is the curl pipe (already at EOF by the time we get here, and
2502
+ # `set -e` would abort on read returning 1).
2503
+ # _spin_cleanup (installed globally above) already restores stty echo on
2504
+ # any exit path, so we don't need a separate trap here — that would
2505
+ # overwrite the spinner cleanup and leak the cursor / child PID on Ctrl-C.
2506
+ printf "%sGet your Weave Router API key at %s%s\n" "$C_BRAND" "$base_url" "$C_RESET"
2507
+ printf "%sPaste your key here (rk_…):%s " "$C_DIM" "$C_RESET"
2508
+ stty -echo </dev/tty 2>/dev/null || true
2509
+ read -r api_key </dev/tty
2510
+ stty echo </dev/tty 2>/dev/null || true
2511
+ printf "\n"
2512
+ api_key_source="prompt"
2513
+ [ -n "$api_key" ] || { err "No key provided."; exit 1; }
2514
+ }
1659
2515
 
1660
2516
  api_key=""
2517
+ installed_key=""
2518
+ [ "$rotate_key" = "true" ] || installed_key="$(read_installed_key)"
2519
+
1661
2520
  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
2521
+ api_key="$WEAVE_ROUTER_KEY"
2522
+ api_key_source="env"
2523
+ info "Using WEAVE_ROUTER_KEY from environment."
2524
+ elif [ -n "$installed_key" ]; then
2525
+ api_key="$installed_key"
2526
+ api_key_source="disk"
2527
+ [ "$quiet" = "true" ] || info "Reusing the router key already installed (pass --rotate-key to replace it)."
2528
+ elif [ "$mode" = "update" ]; then
2529
+ err "No router key found for $target. Export WEAVE_ROUTER_KEY, or run the installer once to set one up."
2530
+ exit 1
2531
+ elif [ "$non_interactive" = "true" ]; then
2532
+ if [ "$rotate_key" = "true" ]; then
2533
+ err "--rotate-key needs either a prompt or WEAVE_ROUTER_KEY, and neither is available. Export the new key and re-run."
1667
2534
  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; }
2535
+ err "--non-interactive set but WEAVE_ROUTER_KEY is empty and no installed key was found. Export it and re-run."
1686
2536
  fi
2537
+ exit 1
2538
+ else
2539
+ # If /dev/tty isn't available (e.g. CI without a controlling terminal) the
2540
+ # user must use --non-interactive.
2541
+ if [ ! -r /dev/tty ]; then
2542
+ err "No controlling terminal — set WEAVE_ROUTER_KEY and re-run with --non-interactive."
2543
+ exit 1
2544
+ fi
2545
+ prompt_for_key
2546
+ fi
1687
2547
 
1688
2548
  # ---------- identity (user email + name) ----------
1689
2549
  #
@@ -1724,11 +2584,12 @@ fi
1724
2584
  # Claude: <settings_dir>/commands/{force-model,unforce-model}.md → /force-model
1725
2585
  # opencode: <commands_dir>/{force-model,unforce-model}.md → /force-model
1726
2586
  #
1727
- # Codex intentionally is not listed: its slash menu only exposes built-in
1728
- # commands and does not load these Markdown wrappers. A user can send a router
1729
- # directive by starting it with one literal space (for example,
1730
- # ` /force-model gpt-5.6-terra`); Codex submits the trimmed prompt to the
1731
- # router without interpreting it as a local slash command.
2587
+ # Codex is intentionally not listed here: its slash menu only exposes built-in
2588
+ # commands and does not load these Markdown wrappers. Codex instead gets native
2589
+ # `$name` skills (see install_codex_prompt_skills), each of which sends the
2590
+ # leading-space prompt form (for example, ` /force-model gpt-5.6-terra`) that
2591
+ # the router parses. Which client gets which directive comes from the embedded
2592
+ # registry, not from a list here.
1732
2593
  #
1733
2594
  # Files come from install/commands/ in the repo (or the colocated commands/
1734
2595
  # directory the npm package ships alongside install.sh).
@@ -1755,13 +2616,22 @@ install_slash_commands() {
1755
2616
  # command-capable clients. The router-off/on/status wrappers shell out to
1756
2617
  # this installer to flip the *local* config, so they're Claude Code-only and
1757
2618
  # need the install scope baked into the command (the .md can't discover it
1758
- # at invocation time). {{SCOPE}} is substituted accordingly.
1759
- installed="force-model, unforce-model, router-feedback, fm, ufm, rf"
1760
- cmds="force-model unforce-model router-feedback fm ufm rf"
1761
- if [ "$target" = "claude" ]; then
1762
- cmds="$cmds router-off router-on router-status router-session"
1763
- installed="$installed, router-off, router-on, router-status, router-session"
1764
- fi
2619
+ # at invocation time). {{SCOPE}} is substituted accordingly. router-models
2620
+ # (alias models) is in the same boat: it shells out to read this install's
2621
+ # endpoint and key.
2622
+ cmds=""
2623
+ while IFS= read -r cmd; do
2624
+ [ -f "$commands_src_dir/$cmd.md" ] || continue
2625
+ case ",$cmd," in
2626
+ *,router-off,*|*,router-on,*|*,router-status,*|*,router-session,*|*,router-models,*|*,models,*)
2627
+ [ "$target" = "claude" ] || continue ;;
2628
+ esac
2629
+ [ -n "$cmds" ] && cmds="$cmds "
2630
+ cmds="$cmds$cmd"
2631
+ done <<EOF
2632
+ $(weave_registry_names "$target")
2633
+ EOF
2634
+ installed="$(printf '%s' "$cmds" | tr ' ' ',' | sed 's/,/, /g')"
1765
2635
 
1766
2636
  # Bake the same scope selector the toggle needs to find this install: --dir
1767
2637
  # overrides scope (mirrors install/uninstall path resolution), so a sandbox
@@ -1785,11 +2655,56 @@ install_slash_commands() {
1785
2655
  # cp-equivalent for the others since the token is absent).
1786
2656
  local body; body="$(cat "$src")"
1787
2657
  body="${body//\{\{SCOPE\}\}/$scope_args}"
1788
- printf '%s\n' "$body" >"$dst"
2658
+ # Wrappers installed before ownership markers existed carry no marker. A
2659
+ # file whose body still matches what this installer would write is one of
2660
+ # ours from an older version, so adopt it rather than treating it as
2661
+ # user-owned — otherwise every pre-marker install is stranded: never
2662
+ # refreshed, never uninstalled, and skipped by the statusline refresh.
2663
+ # Anything that does not match byte-for-byte is left alone.
2664
+ if [ -e "$dst" ] && { [ ! -f "$dst" ] || ! grep -Fq "<!-- weave-router managed command: $cmd -->" "$dst"; }; then
2665
+ if [ -f "$dst" ] && [ "$(cat "$dst")" = "$body" ]; then
2666
+ : # an unmarked copy of our own wrapper; fall through and adopt it
2667
+ else
2668
+ warn "A user-owned $target command already exists at $dst; leaving it untouched."
2669
+ continue
2670
+ fi
2671
+ fi
2672
+ printf '%s\n<!-- weave-router managed command: %s -->' "$body" "$cmd" >"$dst"
1789
2673
  done
2674
+ seed_command_baseline "$commands_src_dir" "$cmds"
1790
2675
  ok "Slash commands written to $dst_dir ($installed)"
1791
2676
  }
1792
2677
 
2678
+ # seed_command_baseline records the canonical (unrendered) wrapper bodies this
2679
+ # run installed, in the cache dir the statusline's background refresh reads. The
2680
+ # refresh replaces an installed wrapper only when its bytes still match that
2681
+ # baseline, which is how it tells "stale copy" from "user edited it" — without a
2682
+ # seed it has to spend one whole interval establishing one. Claude Code only:
2683
+ # the statusline is the refresh point and it only ever runs there.
2684
+ #
2685
+ # Keyed off the statusline's own location, exactly as the refresh derives it, so
2686
+ # both sides land on the same cache path.
2687
+ seed_command_baseline() {
2688
+ local src_dir="$1" names="$2"
2689
+ [ "$target" = "claude" ] || return 0
2690
+ local sl_dir cmd_dir
2691
+ sl_dir="$(cd "$(dirname "$statusline_file")" 2>/dev/null && pwd -P)" || return 0
2692
+ case "${sl_dir##*/}" in
2693
+ .weave) cmd_dir="${sl_dir%/*}/.claude/commands" ;;
2694
+ .claude) cmd_dir="$sl_dir/commands" ;;
2695
+ *) return 0 ;;
2696
+ esac
2697
+ local dir_slug
2698
+ dir_slug="$(printf '%s' "$cmd_dir" | tr -c 'A-Za-z0-9._-' '_')"
2699
+ local baseline_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router/commands${dir_slug}"
2700
+ mkdir -p "$baseline_dir" 2>/dev/null || return 0
2701
+ local name
2702
+ for name in $names; do
2703
+ [ -f "$src_dir/$name.md" ] || continue
2704
+ cp "$src_dir/$name.md" "$baseline_dir/$name.md" 2>/dev/null || true
2705
+ done
2706
+ }
2707
+
1793
2708
  # Codex builds before custom prompt discovery existed were given wrapper files
1794
2709
  # under ~/.codex/prompts. They were never invocable as the advertised aliases.
1795
2710
  # Remove only byte-for-byte copies of our old wrappers; retain any prompt the
@@ -1815,18 +2730,18 @@ remove_obsolete_codex_prompt_wrappers() {
1815
2730
  refuse_if_symlink "$dst_dir"
1816
2731
  fi
1817
2732
 
1818
- for cmd in force-model unforce-model router-feedback fm ufm rf; do
2733
+ while IFS= read -r cmd; do
1819
2734
  src="$commands_src_dir/$cmd.md"
1820
2735
  dst="$dst_dir/$cmd.md"
1821
2736
  [ -f "$src" ] && [ -f "$dst" ] || continue
1822
- # A symlink is user-controlled at user scope. Leave it untouched; project
1823
- # scope rejects it through the same guard used by normal installs.
1824
2737
  [ -L "$dst" ] && continue
1825
2738
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1826
2739
  refuse_if_symlink "$dst"
1827
2740
  fi
1828
2741
  cmp -s "$src" "$dst" && rm -f "$dst"
1829
- done
2742
+ done <<EOF
2743
+ $(weave_registry_names codex)
2744
+ EOF
1830
2745
  rmdir "$dst_dir" 2>/dev/null || true
1831
2746
  }
1832
2747
 
@@ -1879,13 +2794,49 @@ install_codex_disable_routing_skill() {
1879
2794
  ok "Codex skill installed: \$disable-routing"
1880
2795
  }
1881
2796
 
1882
- # ---------- codex install path (dispatch + exit before the Claude-only writes) ----------
2797
+ # Install prompt directives as Codex-native skills. The skill itself emits a
2798
+ # leading-space normal prompt, which is the only portable way to reach the
2799
+ # router directive parser without claiming Codex's reserved slash namespace.
2800
+ install_codex_prompt_skills() {
2801
+ local canonical skill_src dst_dir dst_file scope_args body
2802
+ while IFS= read -r canonical; do
2803
+ skill_src="$script_dir/codex-skills/$canonical/SKILL.md"
2804
+ [ -f "$skill_src" ] || continue
2805
+ grep -Fq "<!-- weave-router managed $canonical skill -->" "$skill_src" || continue
2806
+ dst_dir="$codex_dir/skills/$canonical"
2807
+ dst_file="$dst_dir/SKILL.md"
2808
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
2809
+ refuse_if_symlink "$codex_dir/skills"
2810
+ refuse_if_symlink "$dst_dir"
2811
+ refuse_if_symlink "$dst_file"
2812
+ elif [ -L "$codex_dir/skills" ] || [ -L "$dst_dir" ] || [ -L "$dst_file" ]; then
2813
+ warn "Codex skill path contains a symlink; leaving $canonical untouched."
2814
+ continue
2815
+ fi
2816
+ if [ -e "$dst_file" ] && { [ ! -f "$dst_file" ] || ! grep -Fq "<!-- weave-router managed $canonical skill -->" "$dst_file"; }; then
2817
+ warn "A user-owned Codex $canonical skill already exists at $dst_file; leaving it untouched."
2818
+ continue
2819
+ fi
2820
+ mkdir -p "$dst_dir"
2821
+ scope_args=""
2822
+ if [ -n "$install_dir" ]; then scope_args=" --dir $(printf '%q' "$install_dir")"
2823
+ elif [ "$scope" = project ]; then scope_args=" --scope project"; fi
2824
+ body="$(<"$skill_src")"
2825
+ body="${body//\{\{SCOPE\}\}/$scope_args}"
2826
+ printf '%s\n' "$body" >"$dst_file"
2827
+ ok "Codex skill installed: \$$canonical"
2828
+ done <<EOF
2829
+ $(weave_registry_skill_names codex)
2830
+ EOF
2831
+ }
2832
+
1883
2833
 
1884
2834
  if [ "$target" = "codex" ]; then
1885
2835
  write_codex_config "$codex_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
1886
2836
  ok "Codex config written to $codex_config_file"
1887
2837
  remove_obsolete_codex_prompt_wrappers "$codex_dir/prompts"
1888
2838
  install_codex_disable_routing_skill
2839
+ install_codex_prompt_skills
1889
2840
  info "Codex router directives: begin the message with one space, e.g. ' /force-model gpt-5.6-terra'."
1890
2841
 
1891
2842
  # Project scope: ensure the per-teammate config (which holds the router key)
@@ -2073,6 +3024,10 @@ if [ "$target" = "pi" ]; then
2073
3024
  fi
2074
3025
  fi
2075
3026
 
3027
+ if [ -n "$lsp_langs" ]; then
3028
+ install_lsp_servers "$lsp_langs"
3029
+ fi
3030
+
2076
3031
  printf "\n"
2077
3032
  printf "%s✓%s %s%sWeave Router installed for pi.%s\n" \
2078
3033
  "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
@@ -2217,6 +3172,312 @@ weave_self_refresh() {
2217
3172
  }
2218
3173
  weave_self_refresh 2>/dev/null || true
2219
3174
 
3175
+ # ---------- background slash-command refresh ----------
3176
+ #
3177
+ # The /force-model, /router-* … wrappers under <install>/.claude/commands/ are
3178
+ # written once at install time and then never touched again, so a user who
3179
+ # doesn't re-run the installer keeps whatever set shipped that day. This script
3180
+ # is the only thing Claude Code invokes on every turn, so it doubles as the
3181
+ # refresh point for them: same rate limit, same detached fork, same
3182
+ # content-diff no-op as the self-refresh above.
3183
+ #
3184
+ # Never clobber a wrapper the user edited. The only way to tell an edit from a
3185
+ # stale copy is to remember what we last downloaded, so each canonical file is
3186
+ # cached (unrendered) per install under the user cache dir and a wrapper is
3187
+ # replaced only when its bytes still match that baseline. With no baseline yet
3188
+ # we can't prove anything, so the first run only seeds the cache and a swap can
3189
+ # happen from the next one on. install.sh seeds it too, so fresh installs skip
3190
+ # that warm-up round.
3191
+ #
3192
+ # Never touch a wrapper git tracks either. A project-scope install writes the
3193
+ # wrappers into the repo's own .claude/commands/, and unlike cc-statusline.sh
3194
+ # the installer does not gitignore them — so an unattended weekly rewrite would
3195
+ # surface as unexplained dirty files (and could ride along in someone's commit).
3196
+ # Only the installer changes tracked files, and only when a human runs it.
3197
+ #
3198
+ # Opt out with WEAVE_COMMANDS_UPDATE=0 (WEAVE_STATUSLINE_UPDATE=0 disables this
3199
+ # too, along with every other network path here). Override the source with
3200
+ # WEAVE_COMMANDS_URL_BASE=..., e.g. for self-hosters who fork.
3201
+
3202
+ # weave_command_tracked_by_git returns 0 when $1 is a file git tracks. Used to
3203
+ # leave repo-committed wrappers alone; no git (or no repo) means untracked.
3204
+ weave_command_tracked_by_git() {
3205
+ command -v git >/dev/null 2>&1 || return 1
3206
+ git -C "$(dirname "$1")" ls-files --error-unmatch -- "$1" >/dev/null 2>&1
3207
+ }
3208
+
3209
+ # weave_installed_command_names lists the wrappers this install may refresh.
3210
+ # The statusline ships standalone (no registry.sh beside it), so the installed
3211
+ # set — itself written from the registry — is the only source of truth here.
3212
+ #
3213
+ # Files written before ownership markers existed carry none, so matching on the
3214
+ # marker alone would freeze every pre-marker install out of refreshes forever.
3215
+ # List every wrapper instead and let the baseline comparison below decide: a
3216
+ # file is replaced only when its bytes still match the last canonical copy, so
3217
+ # a user-authored command is never touched whether or not it carries a marker.
3218
+ weave_installed_command_names() {
3219
+ local dir="$1" file
3220
+ for file in "$dir"/*.md; do
3221
+ [ -f "$file" ] || continue
3222
+ printf '%s\n' "$(basename "$file" .md)"
3223
+ done
3224
+ }
3225
+
3226
+ # weave_render_command prints $1 with the installer's {{SCOPE}} placeholder
3227
+ # replaced by $2, matching how install_slash_commands writes the same file.
3228
+ # Trailing newlines are stripped on both sides of every comparison below.
3229
+ weave_render_command() {
3230
+ local body
3231
+ body="$(cat "$1" 2>/dev/null)" || return 1
3232
+ printf '%s' "${body//\{\{SCOPE\}\}/$2}"
3233
+ }
3234
+
3235
+ weave_sync_commands() {
3236
+ [ "${WEAVE_STATUSLINE_UPDATE:-1}" = "0" ] && return 0
3237
+ [ "${WEAVE_COMMANDS_UPDATE:-1}" = "0" ] && return 0
3238
+ command -v curl >/dev/null 2>&1 || return 0
3239
+
3240
+ local self="${BASH_SOURCE[0]:-$0}" self_dir cmd_dir
3241
+ self_dir="$(cd "$(dirname "$self")" 2>/dev/null && pwd -P)" || return 0
3242
+ # User scope installs this script at <base>/.weave/, project and --dir at
3243
+ # <base>/.claude/. Claude Code reads commands from <base>/.claude/commands in
3244
+ # both layouts.
3245
+ case "${self_dir##*/}" in
3246
+ .weave) cmd_dir="${self_dir%/*}/.claude/commands" ;;
3247
+ .claude) cmd_dir="$self_dir/commands" ;;
3248
+ *) return 0 ;;
3249
+ esac
3250
+ [ -d "$cmd_dir" ] && [ -w "$cmd_dir" ] || return 0
3251
+
3252
+ local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router"
3253
+ local dir_slug
3254
+ dir_slug="$(printf '%s' "$cmd_dir" | tr -c 'A-Za-z0-9._-' '_')"
3255
+ # Baseline is keyed by install, not shared: two installs refresh on their own
3256
+ # clocks, and a shared baseline updated by one would make the other's still-
3257
+ # canonical wrappers look user-edited and freeze them forever.
3258
+ local baseline_dir="$cache_dir/commands${dir_slug}"
3259
+ local stamp="$cache_dir/checked-at${dir_slug}.commands"
3260
+ mkdir -p "$baseline_dir" 2>/dev/null || return 0
3261
+
3262
+ local interval_days="${WEAVE_STATUSLINE_UPDATE_INTERVAL_DAYS:-7}"
3263
+ local now stamp_mtime
3264
+ now="$(date +%s 2>/dev/null)" || return 0
3265
+ if [ -f "$stamp" ]; then
3266
+ stamp_mtime="$(stat -c %Y "$stamp" 2>/dev/null || stat -f %m "$stamp" 2>/dev/null)" || stamp_mtime=0
3267
+ else
3268
+ stamp_mtime=0
3269
+ fi
3270
+ if [ -n "${stamp_mtime:-}" ] && [ "$stamp_mtime" -gt 0 ] \
3271
+ && [ $(( now - stamp_mtime )) -lt $(( interval_days * 86400 )) ]; then
3272
+ return 0
3273
+ fi
3274
+ # Stamp before forking, same as the self-refresh: Claude Code calls us on
3275
+ # every turn and concurrent invocations must not all start downloading.
3276
+ : > "$stamp" 2>/dev/null || return 0
3277
+
3278
+ # router-off/on/status bake this install's scope selector into their npx
3279
+ # line (upstream carries {{SCOPE}} there). Recover it from the installed copy
3280
+ # so a project or --dir install keeps toggling its own config; when it can't
3281
+ # be recovered those three are skipped rather than rewritten to point at the
3282
+ # user-scope install.
3283
+ local scope_args="" scope_known="false" off="$cmd_dir/router-off.md"
3284
+ if [ -f "$off" ] && grep -q '^`npx @workweave/router off --claude.*`$' "$off" 2>/dev/null; then
3285
+ scope_known="true"
3286
+ scope_args="$(sed -n 's|^`npx @workweave/router off --claude\(.*\)`$|\1|p' "$off" | head -n 1)"
3287
+ fi
3288
+
3289
+ local url_base="${WEAVE_COMMANDS_URL_BASE:-https://raw.githubusercontent.com/workweave/router/main/install/commands}"
3290
+ local name installed raw prev tmp new_body prev_body installed_body
3291
+ (
3292
+ # Detach stdin (CC pipes JSON to us) so curl can't consume it, and silence
3293
+ # everything so no output leaks into the statusline.
3294
+ exec </dev/null
3295
+ while IFS= read -r name; do
3296
+ installed="$cmd_dir/$name.md"
3297
+ # Only ever refresh a wrapper that is already installed: a missing one
3298
+ # was uninstalled or deliberately deleted, and resurrecting it would be
3299
+ # a surprise. A symlink is user-owned; leave it alone. A git-tracked
3300
+ # wrapper belongs to the repo — rewriting it would dirty a working tree
3301
+ # nobody asked us to touch.
3302
+ [ -f "$installed" ] || continue
3303
+ [ -L "$installed" ] && continue
3304
+ weave_command_tracked_by_git "$installed" && continue
3305
+
3306
+ raw="$baseline_dir/$name.md.tmp.$$"
3307
+ curl -fsSL --max-time 15 "$url_base/$name.md" -o "$raw" 2>/dev/null || { rm -f "$raw"; continue; }
3308
+ # Shape check: every wrapper opens with YAML front matter, so a 404 page
3309
+ # or a truncated body can never be installed as a command.
3310
+ if [ ! -s "$raw" ] || [ "$(head -n 1 "$raw")" != "---" ]; then
3311
+ rm -f "$raw"
3312
+ continue
3313
+ fi
3314
+
3315
+ prev="$baseline_dir/$name.md"
3316
+ if grep -q '{{SCOPE}}' "$raw" && [ "$scope_known" != "true" ]; then
3317
+ mv "$raw" "$prev" 2>/dev/null || rm -f "$raw"
3318
+ continue
3319
+ fi
3320
+
3321
+ if [ -f "$prev" ]; then
3322
+ new_body="$(weave_render_command "$raw" "$scope_args")"
3323
+ prev_body="$(weave_render_command "$prev" "$scope_args")"
3324
+ installed_body="$(cat "$installed" 2>/dev/null | sed '/^<!-- weave-router managed command: .* -->$/d')" || installed_body=""
3325
+ if [ "$prev_body" = "$installed_body" ] && [ "$new_body" != "$installed_body" ]; then
3326
+ tmp="$installed.tmp.$$"
3327
+ if printf '%s\n<!-- weave-router managed command: %s -->' "$new_body" "$name" >"$tmp" 2>/dev/null; then
3328
+ mv "$tmp" "$installed" 2>/dev/null || rm -f "$tmp"
3329
+ else
3330
+ rm -f "$tmp"
3331
+ fi
3332
+ fi
3333
+ fi
3334
+ mv "$raw" "$prev" 2>/dev/null || rm -f "$raw"
3335
+ done <<EOF
3336
+ $(weave_installed_command_names "$cmd_dir")
3337
+ EOF
3338
+ ) >/dev/null 2>&1 &
3339
+ disown 2>/dev/null || true
3340
+ return 0
3341
+ }
3342
+ weave_sync_commands 2>/dev/null || true
3343
+
3344
+ # ---------- org "hide terminal surfaces" gate ----------
3345
+ #
3346
+ # When the org has hidden the router's terminal surfaces, the statusline
3347
+ # renders nothing: this prints no output and exits 0, leaving the slot blank.
3348
+ # The setting comes from GET /v1/display-settings with the install's own
3349
+ # router key, but the foreground path NEVER touches the network: it decides
3350
+ # solely from a per-install cache file (TTL WEAVE_DISPLAY_SETTINGS_TTL_SECONDS,
3351
+ # default 1h), and a missing or stale cache fails open — the statusline
3352
+ # renders normally. When the cache is missing or stale a detached background
3353
+ # refresh re-fetches the setting and rewrites the cache atomically, so the
3354
+ # next turn picks up the fresh value; a refresh failure simply leaves the
3355
+ # cache stale, which keeps failing open rather than pinning the gate closed.
3356
+ #
3357
+ # Claude Code runs the statusline every turn, so several invocations can see a
3358
+ # stale cache at once. Letting each fetch independently is not safe: the
3359
+ # responses can land out of order, and the loser's mv would replace a newer
3360
+ # setting with an older one AND stamp it fresh, pinning the gate on a stale
3361
+ # value for a full TTL. Refreshes are therefore serialized per cache key on a
3362
+ # mkdir mutex (atomic everywhere; flock is absent on macOS), held across both
3363
+ # the fetch and the write. A refresh that finds the mutex held exits rather
3364
+ # than queueing — the in-flight one is at least as fresh as anything it would
3365
+ # fetch, so waiting only to overwrite it is the bug. The one path that is not
3366
+ # strictly exclusive is reclaiming a lock whose holder died; see below.
3367
+ weave_hidden_gate() {
3368
+ command -v curl >/dev/null 2>&1 || return 1
3369
+ command -v jq >/dev/null 2>&1 || return 1
3370
+
3371
+ local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router"
3372
+ mkdir -p "$cache_dir" 2>/dev/null || return 1
3373
+ local self="${BASH_SOURCE[0]:-$0}"
3374
+ local script_slug
3375
+ script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
3376
+ local cache="$cache_dir/display-settings${script_slug}"
3377
+ local ttl="${WEAVE_DISPLAY_SETTINGS_TTL_SECONDS:-3600}"
3378
+
3379
+ local now mtime fresh="false"
3380
+ now="$(date +%s 2>/dev/null)" || now=0
3381
+ if [ -f "$cache" ]; then
3382
+ mtime="$(stat -c %Y "$cache" 2>/dev/null || stat -f %m "$cache" 2>/dev/null)" || mtime=0
3383
+ if [ -n "${mtime:-}" ] && [ "$mtime" -gt 0 ] && [ $(( now - mtime )) -lt "$ttl" ]; then
3384
+ fresh="true"
3385
+ fi
3386
+ fi
3387
+
3388
+ # Foreground decision: only a fresh cache hides the surfaces. Anything else
3389
+ # (no cache, stale cache, unreadable cache) renders normally so a slow or
3390
+ # unreachable router can never stall a turn or wedge the statusline blank.
3391
+ if [ "$fresh" = "true" ]; then
3392
+ [ "$(cat "$cache" 2>/dev/null)" = "1" ]
3393
+ return
3394
+ fi
3395
+
3396
+ # Background refresh for the next invocation. Resolve the router base URL
3397
+ # and key inside the subshell from the Claude Code settings the installer
3398
+ # wrote: project/--dir installs put both under <base>/.claude alongside
3399
+ # this script (key in settings.local.json), while a user-scope install
3400
+ # lives under ~/.weave and reads ~/.claude. Resolve relative to the
3401
+ # script's own location, falling back to user scope, so a project install
3402
+ # never reads (or leaks) the user-scope key. ANTHROPIC_BASE_URL and
3403
+ # WEAVE_ROUTER_BASE_URL may also be set in the environment. A file:// base
3404
+ # URL is the offline/test seam: curl reads it as the response body
3405
+ # directly, so the endpoint path is meaningless for it; real router URLs
3406
+ # (https) get /v1/display-settings appended.
3407
+ (
3408
+ exec </dev/null
3409
+
3410
+ # Take the per-cache-key mutex, or bail. mkdir is the portable atomic
3411
+ # test-and-set. A crashed holder would otherwise block refreshes forever,
3412
+ # so a lock older than the fetch timeout is treated as abandoned and
3413
+ # reclaimed. Releasing from a trap covers every exit path below.
3414
+ lock="$cache.lock"
3415
+ if ! mkdir "$lock" 2>/dev/null; then
3416
+ lock_mtime="$(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock" 2>/dev/null)" || lock_mtime=0
3417
+ lock_now="$(date +%s 2>/dev/null)" || lock_now=0
3418
+ if [ "${lock_mtime:-0}" -le 0 ] || [ $(( lock_now - lock_mtime )) -le 30 ]; then
3419
+ exit 0
3420
+ fi
3421
+ # Reclaiming an abandoned lock must not be delete-then-recreate. With
3422
+ # `rm -rf` + `mkdir`, refreshers that all see the same stale lock each
3423
+ # delete the next one's freshly created directory, so many end up holding
3424
+ # it at once (measured at 50 claimants: 4-11 concurrent holders) and their
3425
+ # writes can land out of order. Renaming is atomic, so only one racer can
3426
+ # move a given directory aside and the losers exit instead of clobbering
3427
+ # the winner (same measurement: 1-2). It is not a perfect mutex — a
3428
+ # straggler can still reclaim the new lock a winner just created, since
3429
+ # nothing distinguishes it from the stale one — but real refreshes arrive
3430
+ # one per turn rather than 50 at once, and the staleness threshold below
3431
+ # is what bounds the rest.
3432
+ dead="$lock.dead.$$"
3433
+ mv "$lock" "$dead" 2>/dev/null || exit 0
3434
+ rm -rf "$dead" 2>/dev/null
3435
+ mkdir "$lock" 2>/dev/null || exit 0
3436
+ fi
3437
+ trap 'rmdir "$lock" 2>/dev/null' EXIT
3438
+
3439
+ self_dir="$(cd "$(dirname "$self")" 2>/dev/null && pwd)"
3440
+ settings_base="$HOME"
3441
+ case "$self_dir" in
3442
+ */.claude) settings_base="${self_dir%/.claude}" ;;
3443
+ esac
3444
+ settings="$settings_base/.claude/settings.json"
3445
+ local_settings="$settings_base/.claude/settings.local.json"
3446
+ base_url="${WEAVE_ROUTER_BASE_URL:-${ANTHROPIC_BASE_URL:-}}"
3447
+ key="${WEAVE_ROUTER_KEY:-}"
3448
+ if [ -z "$key" ] && [ -f "$settings" ]; then
3449
+ key="$(jq -r '.env.ANTHROPIC_CUSTOM_HEADERS // "" | split("\n")[] | select(startswith("X-Weave-Router-Key:")) | sub("^X-Weave-Router-Key:[[:space:]]*";"")' "$settings" 2>/dev/null | head -n1)"
3450
+ fi
3451
+ if [ -z "$key" ] && [ -f "$local_settings" ]; then
3452
+ key="$(jq -r '.env.ANTHROPIC_CUSTOM_HEADERS // "" | split("\n")[] | select(startswith("X-Weave-Router-Key:")) | sub("^X-Weave-Router-Key:[[:space:]]*";"")' "$local_settings" 2>/dev/null | head -n1)"
3453
+ fi
3454
+ if [ -z "$base_url" ] && [ -f "$settings" ]; then
3455
+ base_url="$(jq -r '.env.ANTHROPIC_BASE_URL // empty' "$settings" 2>/dev/null)"
3456
+ fi
3457
+ [ -n "$base_url" ] && [ -n "$key" ] || exit 0
3458
+ url="${base_url%/}"
3459
+ case "$url" in
3460
+ file://*) ;;
3461
+ *) url="$url/v1/display-settings" ;;
3462
+ esac
3463
+ body="$(curl -fsS --max-time 5 -H "X-Weave-Router-Key: $key" "$url" 2>/dev/null)" || exit 0
3464
+ hidden="$(printf '%s' "$body" | jq -r '.hide_terminal_surfaces // false' 2>/dev/null)"
3465
+ tmp="$cache.tmp.$$"
3466
+ if [ "$hidden" = "true" ]; then
3467
+ printf '1' >"$tmp" 2>/dev/null && mv "$tmp" "$cache" 2>/dev/null
3468
+ else
3469
+ printf '0' >"$tmp" 2>/dev/null && mv "$tmp" "$cache" 2>/dev/null
3470
+ fi
3471
+ rm -f "$tmp" 2>/dev/null
3472
+ ) >/dev/null 2>&1 &
3473
+ disown 2>/dev/null || true
3474
+ return 1
3475
+ }
3476
+
3477
+ if weave_hidden_gate </dev/null; then
3478
+ exit 0
3479
+ fi
3480
+
2220
3481
  input="$(cat)"
2221
3482
  transcript_path="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
2222
3483
  # Prefer model.id over display_name: pricing keys + the routed model id in
@@ -2257,6 +3518,7 @@ prices='{
2257
3518
  "claude-sonnet-5": 0.003,
2258
3519
  "deepseek/deepseek-v4-flash": 0.0001134,
2259
3520
  "deepseek/deepseek-v4-pro": 0.00174,
3521
+ "deepseek/deepseek-v4-pro-0813": 0.00174,
2260
3522
  "gemini-2.0-flash": 0.0001,
2261
3523
  "gemini-2.0-flash-lite": 0.000075,
2262
3524
  "gemini-2.5-flash": 0.0003,
@@ -2269,6 +3531,7 @@ prices='{
2269
3531
  "gemini-3.5-flash": 0.0015,
2270
3532
  "gemini-3.5-flash-lite": 0.0003,
2271
3533
  "gemini-3.6-flash": 0.0015,
3534
+ "gemini-3.7-flash": 0.0015,
2272
3535
  "google/gemma-4-26b-a4b-it": 0.00015,
2273
3536
  "gpt-4.1": 0.002,
2274
3537
  "gpt-4.1-mini": 0.0004,
@@ -2288,7 +3551,9 @@ prices='{
2288
3551
  "gpt-5.5-nano": 0.00015,
2289
3552
  "gpt-5.5-pro": 0.03,
2290
3553
  "gpt-5.6-luna": 0.001,
3554
+ "gpt-5.6-luna-pro": 0.001,
2291
3555
  "gpt-5.6-sol": 0.005,
3556
+ "gpt-5.6-sol-pro": 0.005,
2292
3557
  "gpt-5.6-terra": 0.0025,
2293
3558
  "grok-4.5": 0.002,
2294
3559
  "grok-4.6": 0.002,
@@ -2311,7 +3576,8 @@ prices='{
2311
3576
  "xiaomi/mimo-v2.5-pro": 0.001,
2312
3577
  "z-ai/glm-5": 0.001,
2313
3578
  "z-ai/glm-5.1": 0.0014,
2314
- "z-ai/glm-5.2": 0.0014
3579
+ "z-ai/glm-5.2": 0.0014,
3580
+ "z-ai/glm-5.3-flash": 0.00015
2315
3581
  },
2316
3582
  "output": {
2317
3583
  "claude-fable-5": 0.05,
@@ -2328,6 +3594,7 @@ prices='{
2328
3594
  "claude-sonnet-5": 0.015,
2329
3595
  "deepseek/deepseek-v4-flash": 0.0002791,
2330
3596
  "deepseek/deepseek-v4-pro": 0.00348,
3597
+ "deepseek/deepseek-v4-pro-0813": 0.00348,
2331
3598
  "gemini-2.0-flash": 0.0004,
2332
3599
  "gemini-2.0-flash-lite": 0.0003,
2333
3600
  "gemini-2.5-flash": 0.0012,
@@ -2340,6 +3607,7 @@ prices='{
2340
3607
  "gemini-3.5-flash": 0.009,
2341
3608
  "gemini-3.5-flash-lite": 0.0025,
2342
3609
  "gemini-3.6-flash": 0.0075,
3610
+ "gemini-3.7-flash": 0.0075,
2343
3611
  "google/gemma-4-26b-a4b-it": 0.0006,
2344
3612
  "gpt-4.1": 0.008,
2345
3613
  "gpt-4.1-mini": 0.0016,
@@ -2359,7 +3627,9 @@ prices='{
2359
3627
  "gpt-5.5-nano": 0.0006,
2360
3628
  "gpt-5.5-pro": 0.18,
2361
3629
  "gpt-5.6-luna": 0.006,
3630
+ "gpt-5.6-luna-pro": 0.006,
2362
3631
  "gpt-5.6-sol": 0.03,
3632
+ "gpt-5.6-sol-pro": 0.03,
2363
3633
  "gpt-5.6-terra": 0.015,
2364
3634
  "grok-4.5": 0.006,
2365
3635
  "grok-4.6": 0.006,
@@ -2382,7 +3652,8 @@ prices='{
2382
3652
  "xiaomi/mimo-v2.5-pro": 0.003,
2383
3653
  "z-ai/glm-5": 0.0032,
2384
3654
  "z-ai/glm-5.1": 0.0044,
2385
- "z-ai/glm-5.2": 0.0044
3655
+ "z-ai/glm-5.2": 0.0044,
3656
+ "z-ai/glm-5.3-flash": 0.0005
2386
3657
  }
2387
3658
  }'
2388
3659
  # END_GENERATED_PRICES
@@ -2437,6 +3708,12 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2437
3708
  # * unrecognized model → "… isn't a recognized model · keeping
2438
3709
  # automatic routing" — a NO-OP: the prior
2439
3710
  # pin, if any, is left untouched
3711
+ # * listing (historical) → "… pick a model by id …" / "no models are
3712
+ # available to pin …" — also NO-OPs. The
3713
+ # router no longer emits these, but a
3714
+ # transcript written while it did still
3715
+ # carries them, and they must not read as
3716
+ # a clear or a live pin loses its [forced]
2440
3717
  # These persist on disk (the ingress stripper only scrubs them from upstream
2441
3718
  # requests). Classify each weave-router turn newest-first, skip the no-op
2442
3719
  # "rejected" acks, and let the latest real state change decide: an "applied"
@@ -2450,6 +3727,7 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2450
3727
  | ([.message.content[]? | select(.type? == "text") | .text] | join(" ") | gsub("[\n\r]"; " ")) as $t
2451
3728
  | if ($t | test("force-model applied:")) then "APPLIED " + ($t | capture("force-model applied: (?<m>[^ ]+)").m)
2452
3729
  elif ($t | test("isn.t a recognized model")) then "REJECTED"
3730
+ elif ($t | test("pick a model by id|no models are available to pin")) then "REJECTED"
2453
3731
  else "CLEARED" end' 2>/dev/null \
2454
3732
  | grep -m1 -v '^REJECTED$' || true)"
2455
3733
  if [[ "$force_state" == APPLIED\ * ]]; then
@@ -2646,87 +3924,114 @@ tmp_patch="$(mktemp)"
2646
3924
  # leave the cursor hidden if Ctrl-C lands during settings.json patching.
2647
3925
  trap '_spin_cleanup; rm -f "$tmp_patch"' EXIT INT TERM HUP
2648
3926
 
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"
3927
+ # write_claude_settings merges the router config into settings.json (and, in
3928
+ # project scope, the key header into settings.local.json) for the key in $1.
3929
+ # Factored out so the /validate fallback can rewrite both files with a
3930
+ # replacement key without re-running the whole install.
3931
+ write_claude_settings() {
3932
+ local block_key="$1"
3933
+
3934
+ # Claude Code splits ANTHROPIC_CUSTOM_HEADERS on newlines, so multiple headers
3935
+ # ride in the same env var separated by \n. Append identity headers alongside
3936
+ # the router key so a single var carries them all. When email/name are empty
3937
+ # we keep the bare router-key form so a re-install for a user who opted out
3938
+ # cleanly removes the old line.
3939
+ local custom_headers="$router_key_header: $block_key"
3940
+ if [ -n "$user_email" ]; then
3941
+ custom_headers="$custom_headers"$'\n'"X-Weave-User-Email: $user_email"
3942
+ fi
3943
+ if [ -n "$user_name" ]; then
3944
+ custom_headers="$custom_headers"$'\n'"X-Weave-User-Name: $user_name"
3945
+ fi
3946
+ custom_headers="$custom_headers"$'\n'"X-App: claude-code"
2707
3947
 
2708
- # Slash command wrappers see install_slash_commands() below for the why.
2709
- install_slash_commands "$settings_dir/commands"
3948
+ # Setting ANTHROPIC_BASE_URL makes Claude Code treat us as non-first-party and
3949
+ # disable MCP tool-search deferral, inlining every tool schema into every request
3950
+ # — a large uncompactable prefix that can push a session into an autocompact
3951
+ # thrash loop. ENABLE_TOOL_SEARCH=auto restores on-demand loading (Claude Code's
3952
+ # own first-party default).
3953
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
3954
+ jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
3955
+ env: { ANTHROPIC_BASE_URL: $url, ENABLE_TOOL_SEARCH: "auto" },
3956
+ statusLine: { type: "command", command: $sl },
3957
+ attribution: {
3958
+ commit: "Co-Authored-By: Weave Router <router@workweave.ai>",
3959
+ pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
3960
+ }
3961
+ }' >"$tmp_patch"
3962
+ else
3963
+ jq -n --arg url "$base_url" --arg header "$custom_headers" --arg sl "$statusline_path_for_settings" '{
3964
+ env: { ANTHROPIC_BASE_URL: $url, ANTHROPIC_CUSTOM_HEADERS: $header, ENABLE_TOOL_SEARCH: "auto" },
3965
+ statusLine: { type: "command", command: $sl },
3966
+ attribution: {
3967
+ commit: "Co-Authored-By: Weave Router <router@workweave.ai>",
3968
+ pr: "🤖 Generated with [Weave Router](https://router.workweave.ai)"
3969
+ }
3970
+ }' >"$tmp_patch"
3971
+ fi
2710
3972
 
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
3973
+ # Merge with existing settings. Deep-merge env and replace statusLine.
3974
+ # We strip router-owned auth from the existing settings BEFORE merging —
3975
+ # otherwise switching auth mode (key→dev-mode) would leave stale credentials
3976
+ # behind. ANTHROPIC_AUTH_TOKEN/apiKeyHelper are also removed to migrate older
3977
+ # installs that used them for router auth.
3978
+ local merged
3979
+ if [ -f "$settings_file" ]; then
2716
3980
  merged="$(jq -s '.[0] as $a | .[1] as $b
2717
3981
  | $a
2718
3982
  | .env = (($a.env // {} | del(.ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS)) + ($b.env // {}))
2719
3983
  | (if (.env | length) == 0 then del(.env) else . end)
2720
3984
  | del(.apiKeyHelper)
2721
- ' "$local_settings_file" "$tmp_patch")"
2722
- printf '%s\n' "$merged" >"$local_settings_file"
3985
+ | (if $b.statusLine then .statusLine = $b.statusLine else . end)
3986
+ | (if $b.attribution then .attribution = $b.attribution else . end)
3987
+ ' "$settings_file" "$tmp_patch")"
3988
+ printf '%s\n' "$merged" >"$settings_file"
2723
3989
  else
2724
- cp "$tmp_patch" "$local_settings_file"
3990
+ cp "$tmp_patch" "$settings_file"
2725
3991
  fi
2726
- chmod 600 "$local_settings_file"
2727
- ok "Router key header written to $local_settings_file"
3992
+ ok "Settings written to $settings_file"
3993
+
3994
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
3995
+ jq -n --arg header "$custom_headers" --arg router_url "$base_url" '{
3996
+ env: { ANTHROPIC_CUSTOM_HEADERS: $header, WEAVE_ROUTER_BASE_URL: $router_url }
3997
+ }' >"$tmp_patch"
3998
+ if [ -f "$local_settings_file" ]; then
3999
+ # Also strip ANTHROPIC_BASE_URL: `off` in project scope points this file
4000
+ # straight at Anthropic (the committed settings.json carries the router
4001
+ # URL instead). Leaving that override in place would have every request
4002
+ # keep bypassing the router even though this install/update just wrote
4003
+ # a fresh key and reports success.
4004
+ merged="$(jq -s '.[0] as $a | .[1] as $b
4005
+ | $a
4006
+ | .env = (($a.env // {} | del(.ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS, .ANTHROPIC_BASE_URL)) + ($b.env // {}))
4007
+ | (if (.env | length) == 0 then del(.env) else . end)
4008
+ | del(.apiKeyHelper)
4009
+ ' "$local_settings_file" "$tmp_patch")"
4010
+ printf '%s\n' "$merged" >"$local_settings_file"
4011
+ else
4012
+ cp "$tmp_patch" "$local_settings_file"
4013
+ fi
4014
+ chmod 600 "$local_settings_file"
4015
+ ok "Router key header written to $local_settings_file"
4016
+ fi
4017
+ }
4018
+
4019
+ # write_claude_settings rewrites the full router config live, so a parked
4020
+ # sidecar left over from `off` is redundant afterward: the router is back on
4021
+ # and the key/base URL it preserved are in the settings files. Leave it in
4022
+ # place and `status` would keep reporting off, and `off` would no-op against
4023
+ # live router config the user can no longer toggle. Removed AFTER the write
4024
+ # succeeds (not before) — deleting it first would lose the only on-disk copy
4025
+ # of the key if the write itself failed midway under `set -e`.
4026
+ write_claude_settings "$api_key"
4027
+
4028
+ if [ "$target" = "claude" ] && [ -f "$settings_dir/.weave-parked.json" ]; then
4029
+ rm -f "$settings_dir/.weave-parked.json"
2728
4030
  fi
2729
4031
 
4032
+ # Slash command wrappers — see install_slash_commands() below for the why.
4033
+ install_slash_commands "$settings_dir/commands"
4034
+
2730
4035
  # ---------- gitignore for project scope ----------
2731
4036
 
2732
4037
  if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
@@ -2766,13 +4071,45 @@ if [ -n "$api_key" ]; then
2766
4071
  | curl -fsS --max-time 5 --header @- "$base_url/validate"
2767
4072
  }
2768
4073
  if ! spin "Validating API key" validate_key; then
2769
- warn "Router rejected the API key (check it matches the dashboard at $base_url)."
4074
+ # A key we read back off disk can have been revoked or rotated since it was
4075
+ # installed, and reusing it silently would leave a broken install behind
4076
+ # exactly where the old behavior (always prompt) would have fixed it. Ask
4077
+ # once, then rewrite the settings with whatever the user pastes. Anywhere a
4078
+ # prompt isn't available — non-interactive, update, --quiet, no tty, or a
4079
+ # key that didn't come from disk — keep the historical warn-and-continue.
4080
+ if [ "$api_key_source" = "disk" ] && [ "$non_interactive" != "true" ] \
4081
+ && [ "$quiet" != "true" ] && [ -r /dev/tty ]; then
4082
+ warn "The router key already installed was rejected (revoked or rotated)."
4083
+ prompt_for_key
4084
+ write_claude_settings "$api_key"
4085
+ if ! spin "Validating API key" validate_key; then
4086
+ warn "Router rejected the API key (check it matches the dashboard at $base_url)."
4087
+ fi
4088
+ elif [ "$mode" = "update" ]; then
4089
+ # update is meant for cron/scripting: a rejected key is a real failure,
4090
+ # not a note in the log. --quiet callers opted out of the noise, not out
4091
+ # of the nonzero exit — a scheduled run that reports success here would
4092
+ # hide a revoked key indefinitely.
4093
+ if [ "$quiet" = "true" ]; then
4094
+ warn "Router rejected the installed API key. Re-run the installer to set a new one."
4095
+ else
4096
+ err "Router rejected the installed API key. Re-run the installer to set a new one."
4097
+ fi
4098
+ exit 1
4099
+ else
4100
+ warn "Router rejected the API key (check it matches the dashboard at $base_url)."
4101
+ fi
2770
4102
  fi
2771
4103
  fi
2772
4104
 
2773
4105
  # ---------- done ----------
2774
4106
 
2775
4107
  printf "\n"
4108
+ if [ "$mode" = "update" ]; then
4109
+ printf "%s✓%s %s%sWeave Router config refreshed for Claude Code.%s\n" \
4110
+ "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
4111
+ exit 0
4112
+ fi
2776
4113
  printf "%s✓%s %s%sWeave Router installed for Claude Code.%s\n" \
2777
4114
  "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
2778
4115
  print_uninstall_hint