@workweave/router 0.2.10 → 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,6 +37,8 @@
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
@@ -64,6 +66,28 @@
64
66
  # `claude` start; Codex and opencode re-read config every invocation.
65
67
  # Cursor's base URL lives in its own settings UI (no file we own), so there's
66
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.
67
91
 
68
92
  set -euo pipefail
69
93
 
@@ -98,9 +122,22 @@ target_explicit="false"
98
122
  # never-prompting install that reuses the key already on disk. "off"/"on"/
99
123
  # "status" toggle or report an existing install without touching the router
100
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.
101
127
  mode="install"
102
128
  disable_routing_alias="false"
103
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
+
104
141
  # --rotate-key forces the interactive key prompt even when a usable key is
105
142
  # already installed, so a rotated key can replace it.
106
143
  rotate_key="false"
@@ -486,14 +523,9 @@ write_codex_config() {
486
523
  # that share the same router key. The router otherwise has to guess from
487
524
  # User-Agent.
488
525
  headers_parts="${headers_parts}, \"X-App\" = \"codex\""
489
- # Match the public hosted Claude Code installation: Codex needs this
490
- # explicit policy selection because that endpoint otherwise uses its
491
- # cluster default. Self-hosted endpoints may not run the optional HMM
492
- # sidecar, so every custom URL deliberately keeps the router's own default.
493
- # Keep this inside the managed block so a re-install can change it safely.
494
- if [ "$block_url" = "$HOSTED_BASE_URL" ]; then
495
- headers_parts="${headers_parts}, \"X-Weave-Router-Strategy\" = \"hmm\""
496
- 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.
497
529
  local headers_line="http_headers = { ${headers_parts} }"
498
530
 
499
531
  local block
@@ -803,8 +835,8 @@ write_pi_models_config() {
803
835
  models: [
804
836
  { id: "claude-fable-5", name: "Claude Fable 5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 128000 },
805
837
  { id: "claude-opus-5", name: "Claude Opus 5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 128000 },
806
- { id: "claude-opus-4-7", name: "Claude Opus 4.7 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
807
- { 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 },
808
840
  { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 32000 },
809
841
  { id: "gpt-5.6-sol", name: "GPT-5.6 Sol (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1050000, maxTokens: 128000 },
810
842
  { id: "grok-4.5", name: "Grok 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 500000, maxTokens: 131072 },
@@ -859,6 +891,69 @@ write_pi_settings_config() {
859
891
  printf '%s\n' "$merged" >"$settings_file"
860
892
  }
861
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
+
862
957
  # resolve_user_name mirrors resolve_user_email but for display name. Priority:
863
958
  # WEAVE_USER_NAME env override → git config user.name → empty. We don't
864
959
  # prompt for name independently: if email prompting yielded nothing, name
@@ -972,6 +1067,10 @@ while [ $# -gt 0 ]; do
972
1067
  --pi)
973
1068
  target="pi"; target_explicit="true"; shift
974
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
+ ;;
975
1074
  --claude)
976
1075
  # No-op selector for symmetry with --codex / --opencode. Useful in
977
1076
  # pipelines that want to skip the interactive picker without depending
@@ -994,15 +1093,44 @@ while [ $# -gt 0 ]; do
994
1093
  # so `disable-routing --claude` cannot silently change Claude settings.
995
1094
  mode="off"; disable_routing_alias="true"; shift
996
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
+ ;;
997
1106
  -h|--help)
998
1107
  usage 0
999
1108
  ;;
1000
1109
  *)
1001
- 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
1002
1120
  ;;
1003
1121
  esac
1004
1122
  done
1005
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
+
1006
1134
  if [ "$disable_routing_alias" = "true" ]; then
1007
1135
  if [ "$target_explicit" = "true" ] && [ "$target" != "codex" ]; then
1008
1136
  err "disable-routing only applies to Codex; omit the client flag or use --codex."
@@ -1014,15 +1142,39 @@ fi
1014
1142
 
1015
1143
  # Toggle verbs only flip config install.sh already wrote: no key, no identity,
1016
1144
  # no prompts. Require an explicit client so we never guess which config to
1017
- # touch, and suppress every interactive prompt downstream.
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.
1018
1148
  if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
1019
1149
  non_interactive="true"
1020
1150
  if [ "$target_explicit" != "true" ]; then
1021
- 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
1022
1156
  exit 2
1023
1157
  fi
1024
1158
  fi
1025
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
+
1026
1178
  # `update` is an install that never prompts: it refreshes the managed config
1027
1179
  # and assets using the key already on disk (or in the environment). Suppressing
1028
1180
  # prompts here also settles the target/scope pickers below on their defaults,
@@ -1156,7 +1308,9 @@ fi
1156
1308
 
1157
1309
  # ---------- pre-flight ----------
1158
1310
 
1159
- if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
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
1160
1314
  [ "$quiet" = "true" ] || info "mode=${C_BOLD}${mode}${C_RESET} scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET}"
1161
1315
  fi
1162
1316
 
@@ -1167,12 +1321,17 @@ fi
1167
1321
  if [ "$target" = "claude" ] || [ "$target" = "opencode" ] || [ "$target" = "pi" ]; then
1168
1322
  require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
1169
1323
  fi
1170
- # curl is only used by the install/update paths' health/validate probes;
1171
- # toggles never hit the network.
1172
- if [ "$mode" = "install" ] || [ "$mode" = "update" ]; then
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
1173
1327
  require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
1174
1328
  fi
1175
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
1176
1335
  case "$target" in
1177
1336
  claude)
1178
1337
  if ! command -v claude >/dev/null 2>&1; then
@@ -1199,9 +1358,118 @@ case "$target" in
1199
1358
  fi
1200
1359
  ;;
1201
1360
  esac
1361
+ fi
1202
1362
 
1203
1363
  script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
1204
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
+
1205
1473
  # Resolve the base directory. User scope always uses $HOME. Project scope uses
1206
1474
  # --dir if given, otherwise the CWD's git root. --dir alone (no --scope) is a
1207
1475
  # throwaway user-style install.
@@ -1396,6 +1664,105 @@ claude_key_present() {
1396
1664
  [ -n "$(read_claude_key "$1")" ]
1397
1665
  }
1398
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
1764
+ }
1765
+
1399
1766
  # gitignore_add appends an entry to the repo .gitignore in project scope so a
1400
1767
  # parked sidecar (which may carry the router key header) never gets committed.
1401
1768
  # No-op for user scope and --dir, matching how install handles its own ignores.
@@ -1698,6 +2065,393 @@ toggle_opencode() {
1698
2065
  esac
1699
2066
  }
1700
2067
 
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
+
1701
2455
  if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
1702
2456
  case "$target" in
1703
2457
  claude) toggle_claude ;;
@@ -1719,16 +2473,7 @@ fi
1719
2473
  # sidecar is the authority while toggled off — reading the live file there would
1720
2474
  # pin the install to api.anthropic.com.
1721
2475
  if [ "$mode" = "update" ] && [ "$base_url_explicit" != "true" ] && [ "$target" = "claude" ]; then
1722
- installed_base=""
1723
- for candidate in \
1724
- "$settings_dir/.weave-parked.json" \
1725
- "$settings_file" \
1726
- "$local_settings_file"
1727
- do
1728
- installed_base="$(json_get "$candidate" '.env.ANTHROPIC_BASE_URL')"
1729
- router_shaped_url "$installed_base" && break
1730
- installed_base=""
1731
- done
2476
+ installed_base="$(resolve_installed_base_url)"
1732
2477
  if [ -n "$installed_base" ]; then
1733
2478
  base_url="${installed_base%/}"
1734
2479
  fi
@@ -1747,30 +2492,7 @@ fi
1747
2492
  # a re-run painless — the installer refreshes assets and config shape often
1748
2493
  # enough that users re-run it routinely, and re-pasting a key every time is pure
1749
2494
  # friction. --rotate-key skips the read-back so a new key can replace the old.
1750
-
1751
- # read_installed_key prints the router key this install already has on disk, or
1752
- # nothing. Only Claude Code is supported today; the other targets still prompt.
1753
- read_installed_key() {
1754
- [ "$target" = "claude" ] || return 0
1755
- local key=""
1756
- # Mirror where the install path writes the key: project scope (no --dir) puts
1757
- # it in the gitignored settings.local.json, everything else inlines it into
1758
- # settings.json. Check the other file too — a scope was possibly changed, or a
1759
- # project checkout may carry a committed header from an older install.
1760
- if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1761
- key="$(read_claude_key "$local_settings_file")"
1762
- [ -n "$key" ] || key="$(read_claude_key "$settings_file")"
1763
- else
1764
- key="$(read_claude_key "$settings_file")"
1765
- [ -n "$key" ] || key="$(read_claude_key "$local_settings_file")"
1766
- fi
1767
- # Last resort: `off` moves the key header out of the settings files and into
1768
- # the parked sidecar, so an install/update run while toggled off finds nothing
1769
- # above even though the key is still on disk. Same {"env":{…}} shape, so
1770
- # read_claude_key reads it directly.
1771
- [ -n "$key" ] || key="$(read_claude_key "$settings_dir/.weave-parked.json")"
1772
- printf '%s' "$key"
1773
- }
2495
+ # read_installed_key itself is defined above, next to the other settings readers.
1774
2496
 
1775
2497
  # prompt_for_key reads a key from the controlling terminal into $api_key. Only
1776
2498
  # ever called on an interactive install; callers check first.
@@ -1862,11 +2584,12 @@ fi
1862
2584
  # Claude: <settings_dir>/commands/{force-model,unforce-model}.md → /force-model
1863
2585
  # opencode: <commands_dir>/{force-model,unforce-model}.md → /force-model
1864
2586
  #
1865
- # Codex intentionally is not listed: its slash menu only exposes built-in
1866
- # commands and does not load these Markdown wrappers. A user can send a router
1867
- # directive by starting it with one literal space (for example,
1868
- # ` /force-model gpt-5.6-terra`); Codex submits the trimmed prompt to the
1869
- # 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.
1870
2593
  #
1871
2594
  # Files come from install/commands/ in the repo (or the colocated commands/
1872
2595
  # directory the npm package ships alongside install.sh).
@@ -1893,13 +2616,22 @@ install_slash_commands() {
1893
2616
  # command-capable clients. The router-off/on/status wrappers shell out to
1894
2617
  # this installer to flip the *local* config, so they're Claude Code-only and
1895
2618
  # need the install scope baked into the command (the .md can't discover it
1896
- # at invocation time). {{SCOPE}} is substituted accordingly.
1897
- installed="force-model, unforce-model, router-feedback, fm, ufm, rf"
1898
- cmds="force-model unforce-model router-feedback fm ufm rf"
1899
- if [ "$target" = "claude" ]; then
1900
- cmds="$cmds router-off router-on router-status router-session"
1901
- installed="$installed, router-off, router-on, router-status, router-session"
1902
- 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')"
1903
2635
 
1904
2636
  # Bake the same scope selector the toggle needs to find this install: --dir
1905
2637
  # overrides scope (mirrors install/uninstall path resolution), so a sandbox
@@ -1923,7 +2655,21 @@ install_slash_commands() {
1923
2655
  # cp-equivalent for the others since the token is absent).
1924
2656
  local body; body="$(cat "$src")"
1925
2657
  body="${body//\{\{SCOPE\}\}/$scope_args}"
1926
- 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"
1927
2673
  done
1928
2674
  seed_command_baseline "$commands_src_dir" "$cmds"
1929
2675
  ok "Slash commands written to $dst_dir ($installed)"
@@ -1984,18 +2730,18 @@ remove_obsolete_codex_prompt_wrappers() {
1984
2730
  refuse_if_symlink "$dst_dir"
1985
2731
  fi
1986
2732
 
1987
- for cmd in force-model unforce-model router-feedback fm ufm rf; do
2733
+ while IFS= read -r cmd; do
1988
2734
  src="$commands_src_dir/$cmd.md"
1989
2735
  dst="$dst_dir/$cmd.md"
1990
2736
  [ -f "$src" ] && [ -f "$dst" ] || continue
1991
- # A symlink is user-controlled at user scope. Leave it untouched; project
1992
- # scope rejects it through the same guard used by normal installs.
1993
2737
  [ -L "$dst" ] && continue
1994
2738
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1995
2739
  refuse_if_symlink "$dst"
1996
2740
  fi
1997
2741
  cmp -s "$src" "$dst" && rm -f "$dst"
1998
- done
2742
+ done <<EOF
2743
+ $(weave_registry_names codex)
2744
+ EOF
1999
2745
  rmdir "$dst_dir" 2>/dev/null || true
2000
2746
  }
2001
2747
 
@@ -2048,13 +2794,49 @@ install_codex_disable_routing_skill() {
2048
2794
  ok "Codex skill installed: \$disable-routing"
2049
2795
  }
2050
2796
 
2051
- # ---------- 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
+
2052
2833
 
2053
2834
  if [ "$target" = "codex" ]; then
2054
2835
  write_codex_config "$codex_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
2055
2836
  ok "Codex config written to $codex_config_file"
2056
2837
  remove_obsolete_codex_prompt_wrappers "$codex_dir/prompts"
2057
2838
  install_codex_disable_routing_skill
2839
+ install_codex_prompt_skills
2058
2840
  info "Codex router directives: begin the message with one space, e.g. ' /force-model gpt-5.6-terra'."
2059
2841
 
2060
2842
  # Project scope: ensure the per-teammate config (which holds the router key)
@@ -2242,6 +3024,10 @@ if [ "$target" = "pi" ]; then
2242
3024
  fi
2243
3025
  fi
2244
3026
 
3027
+ if [ -n "$lsp_langs" ]; then
3028
+ install_lsp_servers "$lsp_langs"
3029
+ fi
3030
+
2245
3031
  printf "\n"
2246
3032
  printf "%s✓%s %s%sWeave Router installed for pi.%s\n" \
2247
3033
  "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
@@ -2420,6 +3206,23 @@ weave_command_tracked_by_git() {
2420
3206
  git -C "$(dirname "$1")" ls-files --error-unmatch -- "$1" >/dev/null 2>&1
2421
3207
  }
2422
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
+
2423
3226
  # weave_render_command prints $1 with the installer's {{SCOPE}} placeholder
2424
3227
  # replaced by $2, matching how install_slash_commands writes the same file.
2425
3228
  # Trailing newlines are stripped on both sides of every comparison below.
@@ -2489,8 +3292,7 @@ weave_sync_commands() {
2489
3292
  # Detach stdin (CC pipes JSON to us) so curl can't consume it, and silence
2490
3293
  # everything so no output leaks into the statusline.
2491
3294
  exec </dev/null
2492
- for name in force-model unforce-model router-feedback fm ufm rf \
2493
- router-off router-on router-status router-session; do
3295
+ while IFS= read -r name; do
2494
3296
  installed="$cmd_dir/$name.md"
2495
3297
  # Only ever refresh a wrapper that is already installed: a missing one
2496
3298
  # was uninstalled or deliberately deleted, and resurrecting it would be
@@ -2519,10 +3321,10 @@ weave_sync_commands() {
2519
3321
  if [ -f "$prev" ]; then
2520
3322
  new_body="$(weave_render_command "$raw" "$scope_args")"
2521
3323
  prev_body="$(weave_render_command "$prev" "$scope_args")"
2522
- installed_body="$(cat "$installed" 2>/dev/null)" || installed_body=""
3324
+ installed_body="$(cat "$installed" 2>/dev/null | sed '/^<!-- weave-router managed command: .* -->$/d')" || installed_body=""
2523
3325
  if [ "$prev_body" = "$installed_body" ] && [ "$new_body" != "$installed_body" ]; then
2524
3326
  tmp="$installed.tmp.$$"
2525
- if printf '%s\n' "$new_body" >"$tmp" 2>/dev/null; then
3327
+ if printf '%s\n<!-- weave-router managed command: %s -->' "$new_body" "$name" >"$tmp" 2>/dev/null; then
2526
3328
  mv "$tmp" "$installed" 2>/dev/null || rm -f "$tmp"
2527
3329
  else
2528
3330
  rm -f "$tmp"
@@ -2530,13 +3332,152 @@ weave_sync_commands() {
2530
3332
  fi
2531
3333
  fi
2532
3334
  mv "$raw" "$prev" 2>/dev/null || rm -f "$raw"
2533
- done
3335
+ done <<EOF
3336
+ $(weave_installed_command_names "$cmd_dir")
3337
+ EOF
2534
3338
  ) >/dev/null 2>&1 &
2535
3339
  disown 2>/dev/null || true
2536
3340
  return 0
2537
3341
  }
2538
3342
  weave_sync_commands 2>/dev/null || true
2539
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
+
2540
3481
  input="$(cat)"
2541
3482
  transcript_path="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
2542
3483
  # Prefer model.id over display_name: pricing keys + the routed model id in
@@ -2577,6 +3518,7 @@ prices='{
2577
3518
  "claude-sonnet-5": 0.003,
2578
3519
  "deepseek/deepseek-v4-flash": 0.0001134,
2579
3520
  "deepseek/deepseek-v4-pro": 0.00174,
3521
+ "deepseek/deepseek-v4-pro-0813": 0.00174,
2580
3522
  "gemini-2.0-flash": 0.0001,
2581
3523
  "gemini-2.0-flash-lite": 0.000075,
2582
3524
  "gemini-2.5-flash": 0.0003,
@@ -2589,6 +3531,7 @@ prices='{
2589
3531
  "gemini-3.5-flash": 0.0015,
2590
3532
  "gemini-3.5-flash-lite": 0.0003,
2591
3533
  "gemini-3.6-flash": 0.0015,
3534
+ "gemini-3.7-flash": 0.0015,
2592
3535
  "google/gemma-4-26b-a4b-it": 0.00015,
2593
3536
  "gpt-4.1": 0.002,
2594
3537
  "gpt-4.1-mini": 0.0004,
@@ -2608,7 +3551,9 @@ prices='{
2608
3551
  "gpt-5.5-nano": 0.00015,
2609
3552
  "gpt-5.5-pro": 0.03,
2610
3553
  "gpt-5.6-luna": 0.001,
3554
+ "gpt-5.6-luna-pro": 0.001,
2611
3555
  "gpt-5.6-sol": 0.005,
3556
+ "gpt-5.6-sol-pro": 0.005,
2612
3557
  "gpt-5.6-terra": 0.0025,
2613
3558
  "grok-4.5": 0.002,
2614
3559
  "grok-4.6": 0.002,
@@ -2631,7 +3576,8 @@ prices='{
2631
3576
  "xiaomi/mimo-v2.5-pro": 0.001,
2632
3577
  "z-ai/glm-5": 0.001,
2633
3578
  "z-ai/glm-5.1": 0.0014,
2634
- "z-ai/glm-5.2": 0.0014
3579
+ "z-ai/glm-5.2": 0.0014,
3580
+ "z-ai/glm-5.3-flash": 0.00015
2635
3581
  },
2636
3582
  "output": {
2637
3583
  "claude-fable-5": 0.05,
@@ -2648,6 +3594,7 @@ prices='{
2648
3594
  "claude-sonnet-5": 0.015,
2649
3595
  "deepseek/deepseek-v4-flash": 0.0002791,
2650
3596
  "deepseek/deepseek-v4-pro": 0.00348,
3597
+ "deepseek/deepseek-v4-pro-0813": 0.00348,
2651
3598
  "gemini-2.0-flash": 0.0004,
2652
3599
  "gemini-2.0-flash-lite": 0.0003,
2653
3600
  "gemini-2.5-flash": 0.0012,
@@ -2660,6 +3607,7 @@ prices='{
2660
3607
  "gemini-3.5-flash": 0.009,
2661
3608
  "gemini-3.5-flash-lite": 0.0025,
2662
3609
  "gemini-3.6-flash": 0.0075,
3610
+ "gemini-3.7-flash": 0.0075,
2663
3611
  "google/gemma-4-26b-a4b-it": 0.0006,
2664
3612
  "gpt-4.1": 0.008,
2665
3613
  "gpt-4.1-mini": 0.0016,
@@ -2679,7 +3627,9 @@ prices='{
2679
3627
  "gpt-5.5-nano": 0.0006,
2680
3628
  "gpt-5.5-pro": 0.18,
2681
3629
  "gpt-5.6-luna": 0.006,
3630
+ "gpt-5.6-luna-pro": 0.006,
2682
3631
  "gpt-5.6-sol": 0.03,
3632
+ "gpt-5.6-sol-pro": 0.03,
2683
3633
  "gpt-5.6-terra": 0.015,
2684
3634
  "grok-4.5": 0.006,
2685
3635
  "grok-4.6": 0.006,
@@ -2702,7 +3652,8 @@ prices='{
2702
3652
  "xiaomi/mimo-v2.5-pro": 0.003,
2703
3653
  "z-ai/glm-5": 0.0032,
2704
3654
  "z-ai/glm-5.1": 0.0044,
2705
- "z-ai/glm-5.2": 0.0044
3655
+ "z-ai/glm-5.2": 0.0044,
3656
+ "z-ai/glm-5.3-flash": 0.0005
2706
3657
  }
2707
3658
  }'
2708
3659
  # END_GENERATED_PRICES
@@ -2757,6 +3708,12 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2757
3708
  # * unrecognized model → "… isn't a recognized model · keeping
2758
3709
  # automatic routing" — a NO-OP: the prior
2759
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]
2760
3717
  # These persist on disk (the ingress stripper only scrubs them from upstream
2761
3718
  # requests). Classify each weave-router turn newest-first, skip the no-op
2762
3719
  # "rejected" acks, and let the latest real state change decide: an "applied"
@@ -2770,6 +3727,7 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2770
3727
  | ([.message.content[]? | select(.type? == "text") | .text] | join(" ") | gsub("[\n\r]"; " ")) as $t
2771
3728
  | if ($t | test("force-model applied:")) then "APPLIED " + ($t | capture("force-model applied: (?<m>[^ ]+)").m)
2772
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"
2773
3731
  else "CLEARED" end' 2>/dev/null \
2774
3732
  | grep -m1 -v '^REJECTED$' || true)"
2775
3733
  if [[ "$force_state" == APPLIED\ * ]]; then
@@ -3034,8 +3992,8 @@ write_claude_settings() {
3034
3992
  ok "Settings written to $settings_file"
3035
3993
 
3036
3994
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
3037
- jq -n --arg header "$custom_headers" '{
3038
- env: { ANTHROPIC_CUSTOM_HEADERS: $header }
3995
+ jq -n --arg header "$custom_headers" --arg router_url "$base_url" '{
3996
+ env: { ANTHROPIC_CUSTOM_HEADERS: $header, WEAVE_ROUTER_BASE_URL: $router_url }
3039
3997
  }' >"$tmp_patch"
3040
3998
  if [ -f "$local_settings_file" ]; then
3041
3999
  # Also strip ANTHROPIC_BASE_URL: `off` in project scope points this file