@workweave/router 0.2.10 → 0.2.12

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.
Files changed (47) hide show
  1. package/README.md +40 -14
  2. package/cc-statusline.sh +274 -23
  3. package/codex-skills/fm/SKILL.md +15 -0
  4. package/codex-skills/fm/scripts/emit.sh +8 -0
  5. package/codex-skills/force-model/SKILL.md +15 -0
  6. package/codex-skills/force-model/scripts/emit.sh +9 -0
  7. package/codex-skills/rf/SKILL.md +15 -0
  8. package/codex-skills/rf/scripts/emit.sh +7 -0
  9. package/codex-skills/router-feedback/SKILL.md +15 -0
  10. package/codex-skills/router-feedback/scripts/emit.sh +9 -0
  11. package/codex-skills/router-models/SKILL.md +51 -0
  12. package/codex-skills/router-off/SKILL.md +22 -0
  13. package/codex-skills/router-on/SKILL.md +22 -0
  14. package/codex-skills/router-status/SKILL.md +19 -0
  15. package/codex-skills/ufm/SKILL.md +14 -0
  16. package/codex-skills/ufm/scripts/emit.sh +3 -0
  17. package/codex-skills/unforce-model/SKILL.md +14 -0
  18. package/codex-skills/unforce-model/scripts/emit.sh +4 -0
  19. package/codex-status.sh +312 -0
  20. package/commands/beta.md +5 -0
  21. package/commands/models.md +46 -0
  22. package/commands/router-models.md +46 -0
  23. package/directives.tsv +13 -0
  24. package/install.sh +1849 -263
  25. package/package.json +7 -1
  26. package/pi-router/README.md +39 -7
  27. package/pi-router/skills/install-lsps/SKILL.md +75 -0
  28. package/pi-router/skills/lsp-guide/SKILL.md +63 -0
  29. package/pi-router/src/beta.ts +21 -0
  30. package/pi-router/src/compaction.ts +46 -8
  31. package/pi-router/src/config.ts +34 -5
  32. package/pi-router/src/context-window.ts +12 -0
  33. package/pi-router/src/dispatch.ts +35 -2
  34. package/pi-router/src/index.ts +12 -1
  35. package/pi-router/src/lsp-broker.ts +255 -0
  36. package/pi-router/src/lsp-client.ts +435 -0
  37. package/pi-router/src/lsp-format.ts +230 -0
  38. package/pi-router/src/lsp-install.ts +215 -0
  39. package/pi-router/src/lsp-protocol.ts +128 -0
  40. package/pi-router/src/lsp-servers.ts +361 -0
  41. package/pi-router/src/lsp.ts +529 -0
  42. package/pi-router/src/pricing.generated.ts +77 -70
  43. package/pi-router/src/provider.ts +15 -2
  44. package/pi-router/src/routed-model.ts +17 -0
  45. package/pi-router/src/savings.ts +1 -1
  46. package/registry.sh +102 -0
  47. package/uninstall.sh +210 -31
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
@@ -47,10 +49,11 @@
47
49
  # npx @workweave/router --uninstall # remove a previous install (delegates to uninstall.sh)
48
50
  #
49
51
  # Re-running the installer reuses the key already on disk, so you only paste it
50
- # once. `update` is the scriptable form of that: it never prompts, refreshes the
51
- # managed config + assets in place, and errors (rather than asking) when no key
52
- # can be found:
52
+ # once — for every client, not just Claude Code. `update` is the scriptable form
53
+ # of that: it never prompts, refreshes the managed config + assets in place, and
54
+ # errors (rather than asking) when no key can be found:
53
55
  # npx @workweave/router update --claude # refresh the Claude Code install in place
56
+ # npx @workweave/router update --codex # same for Codex / opencode / pi
54
57
  #
55
58
  # Toggle an existing install on/off without losing the router config (so
56
59
  # switching back is instant). These never prompt for a key and require an
@@ -64,6 +67,28 @@
64
67
  # `claude` start; Codex and opencode re-read config every invocation.
65
68
  # Cursor's base URL lives in its own settings UI (no file we own), so there's
66
69
  # nothing to toggle here — flip "Override OpenAI Base URL" in Cursor settings.
70
+ #
71
+ # Which router directives each client gets is declared once in
72
+ # install/directives.tsv and installed from there: Claude Code and opencode get
73
+ # Markdown slash commands, Codex gets native `$name` skills (it reserves `/…`
74
+ # for built-ins), pi registers /fm and /ufm in its extension, and Cursor is
75
+ # manual. See install/README.md for the full matrix.
76
+ #
77
+ # Inspect and edit which models this installation lets the router pick from —
78
+ # the same lists the router dashboard's settings page renders. The endpoint and
79
+ # router key both come from the install already on disk, so nothing is prompted
80
+ # and no key is ever passed on the command line:
81
+ # npx @workweave/router models --claude # every model, with its on/off state
82
+ # npx @workweave/router models disable gpt-5.6 --claude # take a model out of rotation
83
+ # npx @workweave/router models enable gpt-5.6 --claude # put it back
84
+ # npx @workweave/router models providers --claude # same, one row per provider
85
+ # npx @workweave/router models providers disable openai --claude # drop a whole provider
86
+ # npx @workweave/router models prefer claude-opus-5 --claude # set the preferred-model ranking
87
+ # npx @workweave/router models list --json --claude # machine-readable, for scripts
88
+ # Editing needs a router that mounts the model-selection API (self-hosted and
89
+ # local routers do). The Weave-hosted router keeps model selection with the
90
+ # organization, in the Weave dashboard — there `models` lists and points you at
91
+ # it rather than editing.
67
92
 
68
93
  set -euo pipefail
69
94
 
@@ -98,9 +123,22 @@ target_explicit="false"
98
123
  # never-prompting install that reuses the key already on disk. "off"/"on"/
99
124
  # "status" toggle or report an existing install without touching the router
100
125
  # key/identity — see the toggle_* helpers and the dispatch block below.
126
+ # "models" reads and edits the installation's model selection over the router's
127
+ # admin API; it touches no local config at all.
101
128
  mode="install"
102
129
  disable_routing_alias="false"
103
130
 
131
+ # `models` sub-verb plus its operands (model / provider ids), newline-delimited
132
+ # in argument order. Catalog ids and provider names never contain whitespace,
133
+ # so a newline-delimited string stays readable where a bash 3.2 array under
134
+ # `set -u` would not. --json switches the output to the raw API payload.
135
+ models_args=""
136
+ models_json="false"
137
+
138
+ # --lsp <langs>: comma-separated languages whose language servers to install
139
+ # for the pi extension's `lsp` tool (pi target only). Empty = don't install any.
140
+ lsp_langs=""
141
+
104
142
  # --rotate-key forces the interactive key prompt even when a usable key is
105
143
  # already installed, so a rotated key can replace it.
106
144
  rotate_key="false"
@@ -316,7 +354,6 @@ refuse_if_symlink() {
316
354
  # --codex) can find and replace the block instead of duplicating it.
317
355
  WEAVE_CODEX_BEGIN_MARKER="# >>> weave-router managed (do not edit between markers) >>>"
318
356
  WEAVE_CODEX_END_MARKER="# <<< weave-router managed <<<"
319
- WEAVE_CODEX_SKILL_MARKER="<!-- weave-router managed disable-routing skill -->"
320
357
 
321
358
  # ---------- identity helpers ----------
322
359
  #
@@ -465,11 +502,12 @@ write_codex_config() {
465
502
  printf '%s' "${s//\"/\\\"}"
466
503
  }
467
504
 
468
- local esc_key esc_email esc_name esc_url
505
+ local esc_key esc_email esc_name esc_url esc_status
469
506
  esc_key="$(toml_escape "$block_key")"
470
507
  esc_email="$(toml_escape "$block_email")"
471
508
  esc_name="$(toml_escape "$block_name")"
472
509
  esc_url="$(toml_escape "$block_url")"
510
+ esc_status="$(toml_escape "$codex_status_file")"
473
511
 
474
512
  # Plant whichever identity values we have alongside the router key so the
475
513
  # router can attribute Codex traffic to a person on shared keys. Build the
@@ -486,22 +524,50 @@ write_codex_config() {
486
524
  # that share the same router key. The router otherwise has to guess from
487
525
  # User-Agent.
488
526
  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
527
+ # No strategy header: pinning one here freezes installed clients on whatever
528
+ # policy was current at install time, so a deployment-default change never
529
+ # reaches them. Every endpoint, hosted or self-hosted, uses its own default.
497
530
  local headers_line="http_headers = { ${headers_parts} }"
498
531
 
532
+ local hook_feature_line="features.hooks = true"
533
+ local hook_block=""
534
+ local codex_hooks_enabled="true"
535
+ if [ -f "$config_file" ]; then
536
+ if awk '
537
+ !in_section && /^[[:space:]]*hooks[[:space:]]*=/ { conflict=1 }
538
+ $0 == "[hooks]" { conflict=1 }
539
+ /^[[:space:]]*\[/ { in_section=1 }
540
+ END { exit(conflict ? 0 : 1) }
541
+ ' "$config_file"; then
542
+ codex_hooks_enabled="false"
543
+ hook_feature_line=""
544
+ fi
545
+ fi
546
+ if [ -f "$config_file" ] && grep -q '^\[features\]$' "$config_file"; then
547
+ hook_feature_line=""
548
+ fi
549
+ if [ "$codex_hooks_enabled" = "true" ]; then
550
+ hook_block="$(cat <<TOML
551
+
552
+ [[hooks.SessionStart]]
553
+ [[hooks.SessionStart.hooks]]
554
+ type = "command"
555
+ command = "${esc_status}"
556
+
557
+ [[hooks.Stop]]
558
+ [[hooks.Stop.hooks]]
559
+ type = "command"
560
+ command = "${esc_status}"
561
+ TOML
562
+ )"
563
+ fi
499
564
  local block
500
565
  block="$(cat <<TOML
501
566
  ${WEAVE_CODEX_BEGIN_MARKER}
502
567
  # Managed by the Weave Router installer. Re-running the installer rewrites
503
568
  # this block; \`./uninstall.sh --codex\` removes it. To opt out without
504
569
  # uninstalling, change the model_provider value below.
570
+ ${hook_feature_line}
505
571
  model_provider = "weave"
506
572
 
507
573
  [model_providers.weave]
@@ -510,6 +576,7 @@ base_url = "${esc_url}/v1"
510
576
  wire_api = "responses"
511
577
  requires_openai_auth = true
512
578
  ${headers_line}
579
+ ${hook_block}
513
580
  ${WEAVE_CODEX_END_MARKER}
514
581
  TOML
515
582
  )"
@@ -564,6 +631,25 @@ TOML
564
631
  else
565
632
  printf "%s\n" "$block" >"$config_file"
566
633
  fi
634
+
635
+ # If the user already has a [features] table, place our managed hook
636
+ # setting in that table instead of using a duplicate dotted key. Preserve an
637
+ # explicit user setting and mark only the line this installer owns.
638
+ if grep -q '^\[features\]$' "$config_file"; then
639
+ if ! awk '
640
+ /^\[features\]$/ { in_features=1; next }
641
+ /^\[/ { in_features=0 }
642
+ in_features && /^[[:space:]]*hooks[[:space:]]*=/ { found=1 }
643
+ END { exit(found ? 0 : 1) }
644
+ ' "$config_file"; then
645
+ tmp="$(mktemp -t weave-codex-features.XXXXXX)"
646
+ awk '/^\[features\]$/ && !inserted { print; print "hooks = true # weave-router managed codex hooks"; inserted=1; next } { print }' "$config_file" >"$tmp"
647
+ mv "$tmp" "$config_file"
648
+ fi
649
+ fi
650
+ if [ "$codex_hooks_enabled" != "true" ]; then
651
+ warn "Existing Codex hooks configuration is not an inline array; preserving it and skipping the Weave status hooks. Routing is still enabled."
652
+ fi
567
653
  # 0600: the file holds a router key. Even at user scope, mode 644 would
568
654
  # leak the key to any local user on a shared box.
569
655
  chmod 600 "$config_file"
@@ -803,8 +889,8 @@ write_pi_models_config() {
803
889
  models: [
804
890
  { id: "claude-fable-5", name: "Claude Fable 5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 128000 },
805
891
  { 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 },
892
+ { id: "claude-opus-4-7", name: "Claude Opus 4.7 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 64000 },
893
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1000000, maxTokens: 64000 },
808
894
  { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 32000 },
809
895
  { id: "gpt-5.6-sol", name: "GPT-5.6 Sol (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 1050000, maxTokens: 128000 },
810
896
  { id: "grok-4.5", name: "Grok 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 500000, maxTokens: 131072 },
@@ -859,6 +945,69 @@ write_pi_settings_config() {
859
945
  printf '%s\n' "$merged" >"$settings_file"
860
946
  }
861
947
 
948
+ # Language-server installs for the pi extension's `lsp` tool (--lsp go,ts,...).
949
+ # The alias -> server -> toolchain matrix mirrors LSP_SERVERS in
950
+ # install/pi-router/src/lsp-servers.ts — keep the two in lockstep. Every
951
+ # failure is a warn-and-continue: a missing toolchain must not fail the router
952
+ # install, and the extension re-offers conversationally at session start.
953
+ install_lsp_servers() {
954
+ local langs="$1" lang
955
+ local seen=" "
956
+ for lang in $(printf '%s' "$langs" | tr ',' ' '); do
957
+ local id="" bin="" alt_bin="" toolchain="" fallback_dir="" gopath=""
958
+ local cmd=""
959
+ case "$(printf '%s' "$lang" | tr '[:upper:]' '[:lower:]')" in
960
+ go|golang)
961
+ id="go"; bin="gopls"; toolchain="go"
962
+ # go install honors $GOBIN, else <first GOPATH element>/bin (default ~/go/bin).
963
+ gopath="${GOPATH:-$HOME/go}"
964
+ fallback_dir="${GOBIN:-${gopath%%:*}/bin}"
965
+ cmd="go install golang.org/x/tools/gopls@latest"
966
+ ;;
967
+ ts|typescript|js|javascript)
968
+ id="typescript"; bin="typescript-language-server"; toolchain="npm"
969
+ cmd="npm i -g typescript-language-server typescript"
970
+ ;;
971
+ py|python)
972
+ id="python"; bin="pyright-langserver"; alt_bin="basedpyright-langserver"; toolchain="npm"
973
+ cmd="npm i -g pyright"
974
+ ;;
975
+ rs|rust)
976
+ id="rust"; bin="rust-analyzer"; toolchain="rustup"
977
+ # rustup honors $CARGO_HOME (default ~/.cargo).
978
+ fallback_dir="${CARGO_HOME:-$HOME/.cargo}/bin"
979
+ cmd="rustup component add rust-analyzer"
980
+ ;;
981
+ *)
982
+ warn "--lsp: unknown language '$lang' (valid: go, typescript, python, rust). Skipping."
983
+ continue
984
+ ;;
985
+ esac
986
+ case "$seen" in *" $id "*) continue ;; esac
987
+ seen="$seen$id "
988
+
989
+ if command -v "$bin" >/dev/null 2>&1 \
990
+ || { [ -n "$alt_bin" ] && command -v "$alt_bin" >/dev/null 2>&1; } \
991
+ || { [ -n "$fallback_dir" ] && [ -x "$fallback_dir/$bin" ]; }; then
992
+ ok "$id language server already installed ($bin)"
993
+ continue
994
+ fi
995
+ if ! command -v "$toolchain" >/dev/null 2>&1; then
996
+ warn "--lsp $id: needs '$toolchain' on PATH (install command: $cmd). Skipping — re-run after installing the $toolchain toolchain."
997
+ continue
998
+ fi
999
+ # shellcheck disable=SC2086 — $cmd is a fixed argv from the case above, never user input.
1000
+ if spin "Installing $id language server" $cmd; then
1001
+ ok "$id language server installed ($cmd)"
1002
+ if [ -n "$fallback_dir" ] && ! command -v "$bin" >/dev/null 2>&1; then
1003
+ info "$bin landed in $fallback_dir (not on PATH). The pi lsp tool finds it there; other tools may need a PATH entry."
1004
+ fi
1005
+ else
1006
+ warn "--lsp $id: '$cmd' failed. Run it manually, or ask pi to enable $id LSP support later."
1007
+ fi
1008
+ done
1009
+ }
1010
+
862
1011
  # resolve_user_name mirrors resolve_user_email but for display name. Priority:
863
1012
  # WEAVE_USER_NAME env override → git config user.name → empty. We don't
864
1013
  # prompt for name independently: if email prompting yielded nothing, name
@@ -972,6 +1121,10 @@ while [ $# -gt 0 ]; do
972
1121
  --pi)
973
1122
  target="pi"; target_explicit="true"; shift
974
1123
  ;;
1124
+ --lsp)
1125
+ lsp_langs="${2:-}"; shift 2
1126
+ [ -n "$lsp_langs" ] || { err "--lsp requires a comma-separated language list (go,typescript,python,rust)."; exit 2; }
1127
+ ;;
975
1128
  --claude)
976
1129
  # No-op selector for symmetry with --codex / --opencode. Useful in
977
1130
  # pipelines that want to skip the interactive picker without depending
@@ -994,15 +1147,44 @@ while [ $# -gt 0 ]; do
994
1147
  # so `disable-routing --claude` cannot silently change Claude settings.
995
1148
  mode="off"; disable_routing_alias="true"; shift
996
1149
  ;;
1150
+ models|--models)
1151
+ # Model selection. Sub-verb and operand collection happens in the
1152
+ # catch-all arm below (guarded on mode="models"), not here — that lets
1153
+ # `--claude`/`--json`/etc. appear before, after, or between operands
1154
+ # instead of only after every operand has been consumed.
1155
+ mode="models"; shift
1156
+ ;;
1157
+ --json)
1158
+ models_json="true"; shift
1159
+ ;;
997
1160
  -h|--help)
998
1161
  usage 0
999
1162
  ;;
1000
1163
  *)
1001
- err "Unknown flag: $1."; usage 2
1164
+ # In `models` mode, a bare (non-dashed) word is a sub-verb or operand —
1165
+ # collect it and keep parsing, so a flag can appear before, after, or
1166
+ # between them (`models --claude enable x` and `models enable x --claude`
1167
+ # both work). A dashed token nothing above matched is still an error,
1168
+ # models mode or not.
1169
+ if [ "$mode" = "models" ] && [ "${1#-}" = "$1" ]; then
1170
+ models_args="${models_args}${models_args:+$'\n'}$1"; shift
1171
+ else
1172
+ err "Unknown flag: $1."; usage 2
1173
+ fi
1002
1174
  ;;
1003
1175
  esac
1004
1176
  done
1005
1177
 
1178
+ # --lsp is a pi-extension feature, so it implies the pi target; combining it
1179
+ # with another explicit target is a contradiction, not a preference.
1180
+ if [ -n "$lsp_langs" ]; then
1181
+ if [ "$target_explicit" = "true" ] && [ "$target" != "pi" ]; then
1182
+ err "--lsp only applies to the pi target (the lsp tool ships in the pi extension). Drop --lsp or use --pi."
1183
+ exit 2
1184
+ fi
1185
+ target="pi"; target_explicit="true"
1186
+ fi
1187
+
1006
1188
  if [ "$disable_routing_alias" = "true" ]; then
1007
1189
  if [ "$target_explicit" = "true" ] && [ "$target" != "codex" ]; then
1008
1190
  err "disable-routing only applies to Codex; omit the client flag or use --codex."
@@ -1014,13 +1196,38 @@ fi
1014
1196
 
1015
1197
  # Toggle verbs only flip config install.sh already wrote: no key, no identity,
1016
1198
  # no prompts. Require an explicit client so we never guess which config to
1017
- # touch, and suppress every interactive prompt downstream.
1199
+ # touch, and suppress every interactive prompt downstream. `models` edits no
1200
+ # local config, but it still reads the endpoint and key out of one install, so
1201
+ # it needs the same explicit choice.
1018
1202
  if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
1019
1203
  non_interactive="true"
1020
1204
  if [ "$target_explicit" != "true" ]; then
1021
- err "'$mode' requires an explicit client: --claude, --codex, or --opencode."
1205
+ if [ "$mode" = "models" ]; then
1206
+ err "'models' requires an explicit client: --claude or --codex."
1207
+ else
1208
+ err "'$mode' requires an explicit client: --claude, --codex, or --opencode."
1209
+ fi
1210
+ exit 2
1211
+ fi
1212
+ fi
1213
+
1214
+ # `models` needs to resolve one install's endpoint + key. Claude Code and Codex
1215
+ # both expose readers for that (resolve_installed_endpoint / read_installed_key);
1216
+ # opencode and pi do not have the endpoint-trust story worked out yet, so they
1217
+ # still fail fast here rather than falling through to the toggle dispatch, which
1218
+ # would flip config nobody asked to change.
1219
+ if [ "$mode" = "models" ] && [ "$target" != "claude" ] && [ "$target" != "codex" ]; then
1220
+ err "'models' supports --claude and --codex only (it resolves the router endpoint from that client's config)."
1221
+ exit 2
1222
+ fi
1223
+
1224
+ # --json prints the API payload verbatim, so nothing else may reach stdout.
1225
+ if [ "$models_json" = "true" ]; then
1226
+ if [ "$mode" != "models" ]; then
1227
+ err "--json only applies to 'models'."
1022
1228
  exit 2
1023
1229
  fi
1230
+ quiet="true"
1024
1231
  fi
1025
1232
 
1026
1233
  # `update` is an install that never prompts: it refreshes the managed config
@@ -1033,16 +1240,14 @@ if [ "$mode" = "update" ]; then
1033
1240
  err "--rotate-key needs a prompt, which 'update' never issues. Re-run the installer without 'update'."
1034
1241
  exit 2
1035
1242
  fi
1036
- if [ "$target" != "claude" ]; then
1037
- err "'update' currently supports --claude only. Re-run the installer for $target (it reuses your installed key)."
1038
- exit 2
1039
- fi
1040
1243
  fi
1041
1244
 
1042
1245
  # Toggle verbs (off/on/status) aren't implemented for pi — its config is a
1043
1246
  # structural models.json/settings.json merge, reversed by the uninstaller
1044
- # rather than a single env/key line we can park and restore.
1045
- if [ "$mode" != "install" ] && [ "$target" = "pi" ]; then
1247
+ # rather than a single env/key line we can park and restore. `update` is not a
1248
+ # toggle: it rewrites that same structural config in place, exactly as install
1249
+ # does, so it belongs with install here.
1250
+ if [ "$mode" != "install" ] && [ "$mode" != "update" ] && [ "$target" = "pi" ]; then
1046
1251
  err "Toggle verbs (off/on/status) aren't supported for --pi. Use 'npx @workweave/router --uninstall --pi' to remove, or re-run the installer to refresh."
1047
1252
  exit 2
1048
1253
  fi
@@ -1156,7 +1361,9 @@ fi
1156
1361
 
1157
1362
  # ---------- pre-flight ----------
1158
1363
 
1159
- if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
1364
+ # `models` skips this line: it prints its own header carrying the endpoint it
1365
+ # resolved, which is the part a reader actually needs.
1366
+ if [ "$mode" != "install" ] && [ "$mode" != "update" ] && [ "$mode" != "models" ]; then
1160
1367
  [ "$quiet" = "true" ] || info "mode=${C_BOLD}${mode}${C_RESET} scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET}"
1161
1368
  fi
1162
1369
 
@@ -1164,15 +1371,26 @@ fi
1164
1371
  # Claude Code's settings.json and opencode's opencode.json patching both use
1165
1372
  # jq to deep-merge / structurally rewrite JSON. Toggling those clients reads
1166
1373
  # and rewrites the same JSON, so jq is required there too.
1167
- if [ "$target" = "claude" ] || [ "$target" = "opencode" ] || [ "$target" = "pi" ]; then
1374
+ #
1375
+ # `models` is the exception for Codex: it renders and edits the router's JSON
1376
+ # payloads with jq regardless of which client's config supplied the endpoint,
1377
+ # so require it there too rather than dying mid-render on `jq: command not
1378
+ # found`. Installing Codex itself still needs no jq.
1379
+ if [ "$target" = "claude" ] || [ "$target" = "opencode" ] || [ "$target" = "pi" ] \
1380
+ || { [ "$mode" = "models" ] && [ "$target" = "codex" ]; }; then
1168
1381
  require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
1169
1382
  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
1383
+ # curl is used by the install/update paths' health/validate probes and by every
1384
+ # `models` call; the on/off/status toggles never hit the network.
1385
+ if [ "$mode" = "install" ] || [ "$mode" = "update" ] || [ "$mode" = "models" ]; then
1173
1386
  require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
1174
1387
  fi
1175
1388
 
1389
+ # Every warning below says the client's config will be written anyway, which is
1390
+ # true for install/update/toggles and false for `models` — it only reads that
1391
+ # config to find the router. Whether the client itself is installed has no
1392
+ # bearing on a model-selection call, so skip the check there entirely.
1393
+ if [ "$mode" != "models" ]; then
1176
1394
  case "$target" in
1177
1395
  claude)
1178
1396
  if ! command -v claude >/dev/null 2>&1; then
@@ -1199,9 +1417,121 @@ case "$target" in
1199
1417
  fi
1200
1418
  ;;
1201
1419
  esac
1420
+ fi
1202
1421
 
1203
1422
  script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
1204
1423
 
1424
+ # ---------- directive registry (embedded) ----------
1425
+ #
1426
+ # install.sh is served standalone (WorkWeave serves it for `curl | sh`), so it
1427
+ # cannot source install/registry.sh or read install/directives.tsv. Both are
1428
+ # embedded here verbatim; install/tests/registry_test.sh asserts they never
1429
+ # drift from the canonical files.
1430
+ WEAVE_REGISTRY_DATA=$(cat <<'WEAVE_REGISTRY_EOF'
1431
+ # Weave Router directive registry
1432
+ # canonical|aliases|capability|claude|codex|opencode|pi|cursor|adapter
1433
+ # aliases are comma-separated; client columns are yes/no. adapter is the native asset kind.
1434
+ force-model|fm|prompt|yes|yes|yes|yes|manual|command,skill
1435
+ unforce-model|ufm|prompt|yes|yes|yes|yes|manual|command,skill
1436
+ router-feedback|rf|prompt|yes|yes|yes|no|manual|command,skill
1437
+ router-off||local-toggle|yes|yes|no|no|manual|command,skill
1438
+ router-on||local-toggle|yes|yes|no|no|manual|command,skill
1439
+ router-status||local-toggle|yes|yes|no|no|manual|command,skill
1440
+ router-session||prompt|yes|no|no|no|manual|command
1441
+ router-models|models|local-toggle|yes|yes|no|no|manual|command,skill
1442
+ disable-routing||local-toggle|no|yes|no|no|manual|skill
1443
+ beta||prompt|yes|no|no|yes|manual|command
1444
+ WEAVE_REGISTRY_EOF
1445
+ )
1446
+
1447
+ # >>> weave-router registry lib >>>
1448
+ weave_registry_rows() {
1449
+ if [ -n "${WEAVE_REGISTRY_DATA:-}" ]; then
1450
+ printf '%s\n' "$WEAVE_REGISTRY_DATA" | awk -F '|' '!/^([[:space:]]*#|[[:space:]]*$)/ { print }'
1451
+ return 0
1452
+ fi
1453
+ local registry="${WEAVE_REGISTRY_FILE:-}"
1454
+ if [ -z "$registry" ]; then
1455
+ local dir
1456
+ dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)" || return 1
1457
+ registry="$dir/directives.tsv"
1458
+ fi
1459
+ [ -f "$registry" ] || return 1
1460
+ awk -F '|' '!/^([[:space:]]*#|[[:space:]]*$)/ { print }' "$registry"
1461
+ }
1462
+
1463
+ # weave_registry_names lists every canonical name and alias a client installs.
1464
+ weave_registry_names() {
1465
+ local target="$1" canonical aliases capability claude codex opencode pi cursor adapter
1466
+ while IFS='|' read -r canonical aliases capability claude codex opencode pi cursor adapter; do
1467
+ case "$target" in
1468
+ claude) [ "$claude" = yes ] || continue ;; codex) [ "$codex" = yes ] || continue ;;
1469
+ opencode) [ "$opencode" = yes ] || continue ;; pi) [ "$pi" = yes ] || continue ;;
1470
+ cursor) [ "$cursor" = yes ] || continue ;;
1471
+ esac
1472
+ printf '%s\n' "$canonical"
1473
+ [ -n "$aliases" ] && printf '%s\n' "$aliases" | tr ',' '\n'
1474
+ done <<EOF
1475
+ $(weave_registry_rows)
1476
+ EOF
1477
+ }
1478
+
1479
+ # weave_registry_skill_names lists the prompt directives a client exposes as a
1480
+ # native skill. Local-config toggles are excluded: they mutate config this
1481
+ # installer owns and are handled per target, not as a generic prompt.
1482
+ weave_registry_skill_names() {
1483
+ local target="$1" canonical aliases capability claude codex opencode pi cursor adapter
1484
+ while IFS='|' read -r canonical aliases capability claude codex opencode pi cursor adapter; do
1485
+ [ "$capability" = prompt ] || continue
1486
+ case "$target" in
1487
+ claude) [ "$claude" = yes ] || continue ;; codex) [ "$codex" = yes ] || continue ;;
1488
+ opencode) [ "$opencode" = yes ] || continue ;; pi) [ "$pi" = yes ] || continue ;;
1489
+ cursor) [ "$cursor" = yes ] || continue ;;
1490
+ esac
1491
+ printf '%s\n' "$canonical"
1492
+ [ -n "$aliases" ] && printf '%s\n' "$aliases" | tr ',' '\n'
1493
+ done <<EOF
1494
+ $(weave_registry_rows)
1495
+ EOF
1496
+ }
1497
+
1498
+ # weave_registry_skill_assets lists every directive a client ships as a skill
1499
+ # file, including local-config toggles such as Codex's disable-routing. Install
1500
+ # and uninstall use this for file management; weave_registry_skill_names is the
1501
+ # narrower prompt-only set used when generating prompt adapters.
1502
+ weave_registry_skill_assets() {
1503
+ local target="$1" canonical aliases capability claude codex opencode pi cursor adapter
1504
+ while IFS='|' read -r canonical aliases capability claude codex opencode pi cursor adapter; do
1505
+ case ",$adapter," in *,skill,*) ;; *) continue ;; esac
1506
+ case "$target" in
1507
+ claude) [ "$claude" = yes ] || continue ;; codex) [ "$codex" = yes ] || continue ;;
1508
+ opencode) [ "$opencode" = yes ] || continue ;; pi) [ "$pi" = yes ] || continue ;;
1509
+ cursor) [ "$cursor" = yes ] || continue ;;
1510
+ esac
1511
+ printf '%s\n' "$canonical"
1512
+ [ -n "$aliases" ] && printf '%s\n' "$aliases" | tr ',' '\n'
1513
+ done <<EOF
1514
+ $(weave_registry_rows)
1515
+ EOF
1516
+ }
1517
+
1518
+ # weave_registry_canonical_for resolves a name or alias to its canonical
1519
+ # directive, and fails when the name is not in the registry at all.
1520
+ weave_registry_canonical_for() {
1521
+ local wanted="$1" canonical aliases capability claude codex opencode pi cursor adapter alias
1522
+ while IFS='|' read -r canonical aliases capability claude codex opencode pi cursor adapter; do
1523
+ [ "$wanted" = "$canonical" ] && { printf '%s' "$canonical"; return 0; }
1524
+ IFS=',' read -ra _aliases <<< "$aliases"
1525
+ for alias in ${_aliases[@]+"${_aliases[@]}"}; do
1526
+ [ "$wanted" = "$alias" ] && { printf '%s' "$canonical"; return 0; }
1527
+ done
1528
+ done <<EOF
1529
+ $(weave_registry_rows)
1530
+ EOF
1531
+ return 1
1532
+ }
1533
+ # <<< weave-router registry lib <<<
1534
+
1205
1535
  # Resolve the base directory. User scope always uses $HOME. Project scope uses
1206
1536
  # --dir if given, otherwise the CWD's git root. --dir alone (no --scope) is a
1207
1537
  # throwaway user-style install.
@@ -1276,10 +1606,18 @@ elif [ "$target" = "codex" ]; then
1276
1606
  # teammate — .codex/config.toml goes in .gitignore in project scope.
1277
1607
  codex_dir="$settings_base/.codex"
1278
1608
  codex_config_file="$codex_dir/config.toml"
1609
+ if [ "$scope" = "user" ] && [ -z "$install_dir" ]; then
1610
+ codex_status_file="$settings_base/.weave/codex-status.sh"
1611
+ else
1612
+ codex_status_file="$codex_dir/weave-status.sh"
1613
+ fi
1614
+ codex_status_disabled_marker="$(dirname "$codex_status_file")/.weave-router-disabled"
1279
1615
 
1280
1616
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1281
1617
  refuse_if_symlink "$codex_dir"
1282
1618
  refuse_if_symlink "$codex_config_file"
1619
+ refuse_if_symlink "$codex_status_file"
1620
+ refuse_if_symlink "$codex_status_disabled_marker"
1283
1621
  fi
1284
1622
 
1285
1623
  mkdir -p "$codex_dir"
@@ -1396,6 +1734,240 @@ claude_key_present() {
1396
1734
  [ -n "$(read_claude_key "$1")" ]
1397
1735
  }
1398
1736
 
1737
+ # key_source_is_own returns 0 when a config file is safe to read a router key
1738
+ # back out of. Codex, opencode, and pi all embed the key in a file that lives
1739
+ # INSIDE the project in project scope, and the installer gitignores each one —
1740
+ # so a file git tracks is by definition not this user's own install. A hostile
1741
+ # checkout could commit one carrying an attacker's rk_ key, and adopting it
1742
+ # would bill the developer's traffic to whoever wrote the repo. Same tracked-file
1743
+ # test models_endpoint_is_trusted applies to a planted endpoint.
1744
+ #
1745
+ # Claude Code deliberately does NOT go through this: read_installed_key is
1746
+ # documented to pick up a committed header from an older install, which is a
1747
+ # supported case there.
1748
+ key_source_is_own() {
1749
+ local f="$1"
1750
+ [ -f "$f" ] || return 1
1751
+ [ -L "$f" ] && return 1
1752
+ # Only project-scoped installs put the file somewhere a repo can reach.
1753
+ [ "$scope" = "project" ] && [ -z "$install_dir" ] || return 0
1754
+ command -v git >/dev/null 2>&1 || return 1
1755
+ git -C "$(dirname "$f")" ls-files --error-unmatch -- "$f" >/dev/null 2>&1 && return 1
1756
+ return 0
1757
+ }
1758
+
1759
+ # read_codex_key prints the router key out of Codex's config.toml, or nothing.
1760
+ # Scoped to the managed block so a key-shaped string the user wrote elsewhere in
1761
+ # the file is never adopted. awk rather than jq on purpose: the Codex path is the
1762
+ # one target that doesn't require jq (see the require_cmd block above).
1763
+ read_codex_key() {
1764
+ local f="$1"
1765
+ key_source_is_own "$f" || return 0
1766
+ awk -v begin="$WEAVE_CODEX_BEGIN_MARKER" -v end="$WEAVE_CODEX_END_MARKER" '
1767
+ $0 == begin { inblk = 1; next }
1768
+ $0 == end { inblk = 0; next }
1769
+ inblk && match($0, /"X-Weave-Router-Key"[[:space:]]*=[[:space:]]*"[^"]*"/) {
1770
+ hdr = substr($0, RSTART, RLENGTH)
1771
+ sub(/^.*=[[:space:]]*"/, "", hdr)
1772
+ sub(/"$/, "", hdr)
1773
+ print hdr
1774
+ exit
1775
+ }
1776
+ ' "$f" 2>/dev/null || true
1777
+ }
1778
+
1779
+ # read_opencode_key prints the router key out of opencode.json's managed
1780
+ # provider, or nothing. The header is the authoritative copy — options.apiKey is
1781
+ # only a parse-time placeholder for the @ai-sdk/openai provider.
1782
+ read_opencode_key() {
1783
+ local f="$1"
1784
+ key_source_is_own "$f" || return 0
1785
+ json_get "$f" '.provider.weave.options.headers["X-Weave-Router-Key"]'
1786
+ }
1787
+
1788
+ # read_pi_key prints the router key this pi install already has. The dedicated
1789
+ # key file comes first — it is what the @workweave/router extension itself reads
1790
+ # at runtime — and models.json is the fallback for an install whose key file was
1791
+ # removed by hand.
1792
+ read_pi_key() {
1793
+ local key=""
1794
+ if key_source_is_own "$pi_key_file"; then
1795
+ key="$(tr -d '[:space:]' <"$pi_key_file" 2>/dev/null || true)"
1796
+ fi
1797
+ if [ -z "$key" ] && key_source_is_own "$pi_models_file"; then
1798
+ key="$(json_get "$pi_models_file" '.providers.weave.headers["X-Weave-Router-Key"]')"
1799
+ fi
1800
+ printf '%s' "$key"
1801
+ }
1802
+
1803
+ # read_installed_key prints the router key this install already has on disk, or
1804
+ # nothing, for whichever client is being installed. Reading it back is what makes
1805
+ # a re-run painless — see the token-handling section below. Defined up here
1806
+ # rather than with the rest of token handling because `models` dispatches (and
1807
+ # needs a key) before that section runs.
1808
+ #
1809
+ # models_key_file_order echoes the precedence the Claude reader uses, so a caller
1810
+ # that needs to know *which* file the key came from can walk the same list (a
1811
+ # command substitution around this function would discard any global it set).
1812
+ # models_config_file_for_target names the single managed config that holds both
1813
+ # the endpoint and the key for a non-Claude client. Endpoint and credential from
1814
+ # the same file are self-consistent, which is exactly the case
1815
+ # models_endpoint_is_trusted already treats as always-safe.
1816
+ models_config_file_for_target() {
1817
+ case "$target" in
1818
+ codex) printf '%s' "$codex_config_file" ;;
1819
+ opencode) printf '%s' "$opencode_config_file" ;;
1820
+ pi) printf '%s' "$pi_models_file" ;;
1821
+ esac
1822
+ }
1823
+
1824
+ models_key_file_order() {
1825
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1826
+ printf '%s\n%s\n%s\n' "$local_settings_file" "$settings_file" "$settings_dir/.weave-parked.json"
1827
+ else
1828
+ printf '%s\n%s\n%s\n' "$settings_file" "$local_settings_file" "$settings_dir/.weave-parked.json"
1829
+ fi
1830
+ }
1831
+
1832
+ read_claude_installed_key() {
1833
+ local key="" candidate
1834
+ # Mirror where the install path writes the key: project scope (no --dir) puts
1835
+ # it in the gitignored settings.local.json, everything else inlines it into
1836
+ # settings.json. Check the other file too — a scope was possibly changed, or a
1837
+ # project checkout may carry a committed header from an older install.
1838
+ # The parked sidecar is last: `off` moves the key header out of the settings
1839
+ # files and into it, so a run while toggled off finds nothing above even
1840
+ # though the key is still on disk. Same {"env":{…}} shape, so read_claude_key
1841
+ # reads it directly.
1842
+ while IFS= read -r candidate; do
1843
+ [ -n "$candidate" ] || continue
1844
+ key="$(read_claude_key "$candidate")"
1845
+ [ -n "$key" ] && break
1846
+ done <<<"$(models_key_file_order)"
1847
+ printf '%s' "$key"
1848
+ }
1849
+
1850
+ read_installed_key() {
1851
+ case "$target" in
1852
+ claude) read_claude_installed_key ;;
1853
+ codex) read_codex_key "$codex_config_file" ;;
1854
+ opencode) read_opencode_key "$opencode_config_file" ;;
1855
+ pi) read_pi_key ;;
1856
+ esac
1857
+ }
1858
+
1859
+ # resolve_installed_base_url prints the router endpoint this Claude Code install
1860
+ # already points at, or nothing when none of its files carry a router-shaped
1861
+ # one. The parked sidecar comes first: `off` moves the router URL there and
1862
+ # leaves api.anthropic.com in the live file, so reading the live file while
1863
+ # toggled off would report Anthropic as the router.
1864
+ #
1865
+ # models_base_file_order echoes that same precedence for callers that need to
1866
+ # know which file supplied the endpoint (see models_key_file_order).
1867
+ models_base_file_order() {
1868
+ printf '%s\n%s\n%s\n' "$settings_dir/.weave-parked.json" "$settings_file" "$local_settings_file"
1869
+ }
1870
+
1871
+ resolve_installed_base_url() {
1872
+ local candidate found
1873
+ while IFS= read -r candidate; do
1874
+ [ -n "$candidate" ] || continue
1875
+ found="$(json_get "$candidate" '.env.ANTHROPIC_BASE_URL')"
1876
+ if router_shaped_url "$found"; then
1877
+ printf '%s' "${found%/}"
1878
+ return 0
1879
+ fi
1880
+ done <<<"$(models_base_file_order)"
1881
+ printf ''
1882
+ }
1883
+
1884
+ # resolve_installed_endpoint prints the router endpoint this install already
1885
+ # points at, for whichever client is being installed, or nothing. `update` uses
1886
+ # it so a refresh never silently retargets a self-hosted install at the hosted
1887
+ # default.
1888
+ #
1889
+ # The stored shapes differ by client and are normalized back to the root the
1890
+ # installer takes on the command line: Codex and opencode hold an OpenAI-style
1891
+ # base ending in /v1, while pi holds the bare root (its Anthropic SDK appends
1892
+ # /v1/messages itself). Same tracked-file gate as the key readers — a config the
1893
+ # repo supplied is not this user's install, whichever field is being read.
1894
+ resolve_installed_endpoint() {
1895
+ local found=""
1896
+ case "$target" in
1897
+ claude)
1898
+ resolve_installed_base_url
1899
+ return 0
1900
+ ;;
1901
+ codex)
1902
+ if key_source_is_own "$codex_config_file"; then
1903
+ found="$(awk -v begin="$WEAVE_CODEX_BEGIN_MARKER" -v end="$WEAVE_CODEX_END_MARKER" '
1904
+ $0 == begin { inblk = 1; next }
1905
+ $0 == end { inblk = 0; next }
1906
+ inblk && match($0, /^[[:space:]]*base_url[[:space:]]*=[[:space:]]*"[^"]*"/) {
1907
+ line = substr($0, RSTART, RLENGTH)
1908
+ sub(/^.*=[[:space:]]*"/, "", line)
1909
+ sub(/"$/, "", line)
1910
+ print line
1911
+ exit
1912
+ }
1913
+ ' "$codex_config_file" 2>/dev/null || true)"
1914
+ fi
1915
+ found="${found%/v1}"
1916
+ ;;
1917
+ opencode)
1918
+ if key_source_is_own "$opencode_config_file"; then
1919
+ found="$(json_get "$opencode_config_file" '.provider.weave.options.baseURL')"
1920
+ fi
1921
+ found="${found%/v1}"
1922
+ ;;
1923
+ pi)
1924
+ if key_source_is_own "$pi_models_file"; then
1925
+ found="$(json_get "$pi_models_file" '.providers.weave.baseUrl')"
1926
+ fi
1927
+ ;;
1928
+ esac
1929
+ printf '%s' "${found%/}"
1930
+ }
1931
+
1932
+ # models_endpoint_is_trusted returns 0 when it is safe to send the router key
1933
+ # resolved from $2 to the endpoint resolved from $1.
1934
+ #
1935
+ # Project scope deliberately splits the two: the endpoint lives in the
1936
+ # committed settings.json (teammates share it) while each teammate's key lives
1937
+ # in the gitignored settings.local.json. That split is fine on its own, but it
1938
+ # means a *hostile* repo can commit a settings.json naming an endpoint it
1939
+ # controls and have this command mail the developer's key to it. Endpoint and
1940
+ # credential from the same file are self-consistent and always fine. When they
1941
+ # differ, the endpoint must be one the user vouched for out-of-band — the
1942
+ # hosted default, or an explicit --base-url — rather than whatever the checkout
1943
+ # happened to contain.
1944
+ models_endpoint_is_trusted() {
1945
+ local url="$1" base_src="$2" key_src="$3"
1946
+ [ "$base_url_explicit" = "true" ] && return 0
1947
+ [ "$url" = "$HOSTED_BASE_URL" ] && return 0
1948
+ [ -n "$base_src" ] && [ "$base_src" = "$key_src" ] && return 0
1949
+ # Project-scoped self-hosted installs intentionally split the endpoint and
1950
+ # key. The installer writes a gitignored marker beside the key; require that
1951
+ # marker to match before trusting the split. A tracked/symlinked local file is
1952
+ # not a teammate's private configuration and cannot vouch for an endpoint.
1953
+ if [ "$key_src" = "$local_settings_file" ] && [ ! -L "$key_src" ]; then
1954
+ local marked_url
1955
+ marked_url="$(json_get "$key_src" '.env.WEAVE_ROUTER_BASE_URL')"
1956
+ if [ "${marked_url%/}" = "${url%/}" ]; then
1957
+ command -v git >/dev/null 2>&1 || return 1
1958
+ git -C "$(dirname "$key_src")" ls-files --error-unmatch -- "$key_src" >/dev/null 2>&1 && return 1
1959
+ return 0
1960
+ fi
1961
+ fi
1962
+ # A repo can only pre-plant a file git tracks; an untracked local file is the
1963
+ # user's own. If the endpoint's file isn't tracked, it wasn't planted. Checked
1964
+ # inline rather than via weave_command_tracked_by_git, which is defined further
1965
+ # down (inside the statusline heredoc's neighborhood) and isn't in scope here.
1966
+ command -v git >/dev/null 2>&1 || return 1
1967
+ git -C "$(dirname "$base_src")" ls-files --error-unmatch -- "$base_src" >/dev/null 2>&1 || return 0
1968
+ return 1
1969
+ }
1970
+
1399
1971
  # gitignore_add appends an entry to the repo .gitignore in project scope so a
1400
1972
  # parked sidecar (which may carry the router key header) never gets committed.
1401
1973
  # No-op for user scope and --dir, matching how install handles its own ignores.
@@ -1595,6 +2167,9 @@ toggle_codex() {
1595
2167
  {print}
1596
2168
  ' "$f" >"$tmp" && mv "$tmp" "$f"
1597
2169
  chmod 600 "$f"
2170
+ if [ -f "$codex_status_file" ] && grep -Fq '<!-- weave-router managed codex status -->' "$codex_status_file"; then
2171
+ "$codex_status_file" --off >/dev/null 2>&1 || true
2172
+ fi
1598
2173
  ok "Codex is ${C_BOLD}off${C_RESET} (default provider). Takes effect on your next 'codex' run."
1599
2174
  ;;
1600
2175
  on)
@@ -1610,6 +2185,9 @@ toggle_codex() {
1610
2185
  {print}
1611
2186
  ' "$f" >"$tmp" && mv "$tmp" "$f"
1612
2187
  chmod 600 "$f"
2188
+ if [ -f "$codex_status_file" ] && grep -Fq '<!-- weave-router managed codex status -->' "$codex_status_file"; then
2189
+ "$codex_status_file" --on >/dev/null 2>&1 || true
2190
+ fi
1613
2191
  ok "Codex is ${C_BOLD}on${C_RESET} (routing through the Weave Router). Takes effect on your next 'codex' run."
1614
2192
  ;;
1615
2193
  esac
@@ -1698,6 +2276,407 @@ toggle_opencode() {
1698
2276
  esac
1699
2277
  }
1700
2278
 
2279
+ # ---------- model selection ----------
2280
+ #
2281
+ # `models` reads and edits the installation's model/provider selection through
2282
+ # the router's /admin/v1 API — the same lists the router dashboard's settings
2283
+ # page renders, backed by the same columns. Nothing local is written: the
2284
+ # endpoint and router key are read out of the install already on disk so a
2285
+ # self-hosted install talks to its own router with its own key.
2286
+ #
2287
+ # The Weave-hosted router runs in `managed` mode and mounts no /admin/v1 at
2288
+ # all — there model selection belongs to the organization and is edited in the
2289
+ # Weave dashboard. That surfaces as a 404, which reads (for a list) as "fall
2290
+ # back to the public catalog and say where to edit" and (for an edit) as a
2291
+ # refusal naming the dashboard.
2292
+
2293
+ # Public, unauthed catalog of everything the router can route to. Mounted in
2294
+ # both deployment modes, so it is the one listing that always works.
2295
+ MODELS_CATALOG_PATH="/v1/router/models"
2296
+ MODELS_DASHBOARD_URL="https://router.workweave.ai/dashboard/settings"
2297
+
2298
+ # Set by models_api on every call.
2299
+ models_http_status=""
2300
+ models_http_body=""
2301
+
2302
+ # models_api METHOD PATH [JSON_BODY] — call the router and capture status+body.
2303
+ # The key rides in on stdin (curl --header @-) rather than a -H argument so it
2304
+ # never appears in the process arg list, which any other local user can read.
2305
+ models_api() {
2306
+ local method="$1" path="$2" body="${3:-}"
2307
+ local out status=""
2308
+ out="$(mktemp)" || { err "Could not create a temp file."; exit 1; }
2309
+ if [ -n "$body" ]; then
2310
+ status="$(printf '%s: %s\n' "$router_key_header" "$api_key" \
2311
+ | curl -sS --max-time 20 -X "$method" \
2312
+ -H 'Content-Type: application/json' --data-binary "$body" \
2313
+ --header @- -o "$out" -w '%{http_code}' "$base_url$path" 2>/dev/null)" || status=""
2314
+ else
2315
+ status="$(printf '%s: %s\n' "$router_key_header" "$api_key" \
2316
+ | curl -sS --max-time 20 -X "$method" \
2317
+ --header @- -o "$out" -w '%{http_code}' "$base_url$path" 2>/dev/null)" || status=""
2318
+ fi
2319
+ models_http_status="$status"
2320
+ models_http_body="$(cat "$out" 2>/dev/null || true)"
2321
+ rm -f "$out"
2322
+ case "$models_http_status" in
2323
+ 2*) return 0 ;;
2324
+ *) return 1 ;;
2325
+ esac
2326
+ }
2327
+
2328
+ # models_api_error prints the router's own `error` field, when it sent one.
2329
+ models_api_error() {
2330
+ printf '%s' "$models_http_body" | jq -r '.error // empty' 2>/dev/null || true
2331
+ }
2332
+
2333
+ # models_fail turns the status left by models_api into a message that says what
2334
+ # to do next, then exits. $1 names the attempted operation.
2335
+ models_fail() {
2336
+ local what="$1" detail
2337
+ detail="$(models_api_error)"
2338
+ case "$models_http_status" in
2339
+ ""|000)
2340
+ err "Could not reach the router at $base_url. Is it running?"
2341
+ ;;
2342
+ 401|403)
2343
+ # 403 with a message is the ROUTER_EXCLUDED_MODELS/PROVIDERS env pin,
2344
+ # which is actionable on its own; a bare 401/403 is a key problem.
2345
+ if [ -n "$detail" ]; then
2346
+ err "$detail"
2347
+ else
2348
+ err "The router rejected this installation's key. Re-run 'npx @workweave/router --$target --rotate-key' to install a current one."
2349
+ fi
2350
+ ;;
2351
+ 404)
2352
+ err "This router does not expose the model-selection API, so $what is not available here."
2353
+ printf " %sWeave-hosted routers keep model selection with the organization — edit it at %s%s\n" \
2354
+ "$C_DIM" "$MODELS_DASHBOARD_URL" "$C_RESET" >&2
2355
+ printf " %sSelf-hosted? Update the router to a build that serves /admin/v1/models.%s\n" \
2356
+ "$C_DIM" "$C_RESET" >&2
2357
+ ;;
2358
+ *)
2359
+ if [ -n "$detail" ]; then
2360
+ err "$what failed (HTTP $models_http_status): $detail"
2361
+ else
2362
+ err "$what failed (HTTP $models_http_status)."
2363
+ fi
2364
+ ;;
2365
+ esac
2366
+ exit 1
2367
+ }
2368
+
2369
+ # models_render_list prints the [{model,provider,enabled}] payload as a
2370
+ # provider-grouped checklist. The API sorts by provider then model, which is
2371
+ # what group_by needs, and what keeps two runs comparable.
2372
+ models_render_list() {
2373
+ local payload="$1" total enabled
2374
+ total="$(printf '%s' "$payload" | jq 'length')"
2375
+ enabled="$(printf '%s' "$payload" | jq '[.[] | select(.enabled)] | length')"
2376
+ printf "%s%sWeave Router models%s %s· %s%s\n" "$C_BOLD" "$C_BRAND" "$C_RESET" "$C_DIM" "$base_url" "$C_RESET"
2377
+ printf "%s%s of %s enabled%s\n\n" "$C_DIM" "$enabled" "$total" "$C_RESET"
2378
+ printf '%s' "$payload" | jq -r --arg on "$C_GREEN" --arg off "$C_DIM" --arg reset "$C_RESET" --arg bold "$C_BOLD" '
2379
+ group_by(.provider)[]
2380
+ | ($bold + .[0].provider + $reset),
2381
+ (.[] | if .enabled
2382
+ then " " + $on + "[x]" + $reset + " " + .model
2383
+ else " " + $off + "[ ] " + .model + $reset
2384
+ end)
2385
+ '
2386
+ }
2387
+
2388
+ # models_render_providers prints the [{provider,enabled}] payload.
2389
+ models_render_providers() {
2390
+ local payload="$1"
2391
+ printf "%s%sWeave Router providers%s %s· %s%s\n\n" "$C_BOLD" "$C_BRAND" "$C_RESET" "$C_DIM" "$base_url" "$C_RESET"
2392
+ printf '%s' "$payload" | jq -r --arg on "$C_GREEN" --arg off "$C_DIM" --arg reset "$C_RESET" '
2393
+ .[] | if .enabled
2394
+ then " " + $on + "[x]" + $reset + " " + .provider
2395
+ else " " + $off + "[ ] " + .provider + $reset
2396
+ end
2397
+ '
2398
+ }
2399
+
2400
+ # models_print_preferred appends the priority ranking when one is set. Absent
2401
+ # is the norm (the router picks per turn), so silence is the right output.
2402
+ models_print_preferred() {
2403
+ models_api GET "/admin/v1/preferred-models" || return 0
2404
+ local list
2405
+ list="$(printf '%s' "$models_http_body" | jq -r '(.preferred // []) | join(" > ")' 2>/dev/null || true)"
2406
+ [ -n "$list" ] || return 0
2407
+ printf "\n%sPreferred order:%s %s\n" "$C_DIM" "$C_RESET" "$list"
2408
+ }
2409
+
2410
+ # models_list_catalog is the read-only fallback for a router with no
2411
+ # model-selection API: list what it can route to and say where to change it.
2412
+ # Deliberately not rendered as a checklist — this endpoint reports the deployed
2413
+ # catalog, not the installation's selection, so marking every row [x] would
2414
+ # claim models are enabled that the dashboard may well have excluded.
2415
+ #
2416
+ # $1 selects what to print from the same catalog payload: "models" (the
2417
+ # default) or "providers" — `models providers` hits this same 404 and must
2418
+ # degrade the same way `models list` does rather than hard-failing, since both
2419
+ # are read-only listing commands.
2420
+ models_list_catalog() {
2421
+ local what="${1:-models}"
2422
+ models_api GET "$MODELS_CATALOG_PATH" || models_fail "listing $what"
2423
+ if [ "$models_json" = "true" ]; then
2424
+ if [ "$what" = "providers" ]; then
2425
+ printf '%s' "$models_http_body" | jq -c '[.models[].provider] | unique'
2426
+ else
2427
+ printf '%s\n' "$models_http_body"
2428
+ fi
2429
+ return 0
2430
+ fi
2431
+ printf "%s%sWeave Router %s%s %s· %s%s\n" "$C_BOLD" "$C_BRAND" "$what" "$C_RESET" "$C_DIM" "$base_url" "$C_RESET"
2432
+ printf "%severything this router can route to%s\n\n" "$C_DIM" "$C_RESET"
2433
+ if [ "$what" = "providers" ]; then
2434
+ printf '%s' "$models_http_body" | jq -r '[.models[].provider] | unique[] | " " + .'
2435
+ else
2436
+ printf '%s' "$models_http_body" | jq -r --arg reset "$C_RESET" --arg bold "$C_BOLD" '
2437
+ .models | group_by(.provider)[]
2438
+ | ($bold + .[0].provider + $reset),
2439
+ (.[] | " " + .model)
2440
+ '
2441
+ fi
2442
+ printf "\n%sThis router does not report which of them your installation has enabled:%s\n" "$C_DIM" "$C_RESET"
2443
+ printf "%sselection is an organization-wide setting here. See it, and change it, at%s\n" "$C_DIM" "$C_RESET"
2444
+ printf "%s%s%s\n" "$C_DIM" "$MODELS_DASHBOARD_URL" "$C_RESET"
2445
+ }
2446
+
2447
+ models_list() {
2448
+ if ! models_api GET "/admin/v1/models"; then
2449
+ # Only a missing API is worth degrading for; anything else is a real error.
2450
+ [ "$models_http_status" = "404" ] || models_fail "listing models"
2451
+ models_list_catalog models
2452
+ return 0
2453
+ fi
2454
+ if [ "$models_json" = "true" ]; then
2455
+ printf '%s\n' "$models_http_body"
2456
+ return 0
2457
+ fi
2458
+ models_render_list "$models_http_body"
2459
+ models_print_preferred
2460
+ printf "\n%sEnable a model:%s npx @workweave/router models enable <id> --%s\n" "$C_DIM" "$C_RESET" "$target"
2461
+ printf "%sDisable a model:%s npx @workweave/router models disable <id> --%s\n" "$C_DIM" "$C_RESET" "$target"
2462
+ }
2463
+
2464
+ models_providers_list() {
2465
+ if ! models_api GET "/admin/v1/providers"; then
2466
+ # Same 404 degrade as models_list: a router with no model-selection API
2467
+ # still answers the unauthed catalog, and this is a read-only listing
2468
+ # command same as `models list` — no reason to hard-fail one and not
2469
+ # the other.
2470
+ [ "$models_http_status" = "404" ] || models_fail "listing providers"
2471
+ models_list_catalog providers
2472
+ return 0
2473
+ fi
2474
+ if [ "$models_json" = "true" ]; then
2475
+ printf '%s\n' "$models_http_body"
2476
+ return 0
2477
+ fi
2478
+ models_render_providers "$models_http_body"
2479
+ }
2480
+
2481
+ # models_toggle KIND ACTION IDS — flip one or more models/providers on or off.
2482
+ # Each id is its own request so a typo in the third id can't roll back the two
2483
+ # that already applied, and so the report names exactly what changed.
2484
+ models_toggle() {
2485
+ local kind="$1" action="$2" ids="$3"
2486
+ local path body id label doing
2487
+ case "$kind" in
2488
+ model) path="/admin/v1/excluded-models" ; label="model" ;;
2489
+ provider) path="/admin/v1/excluded-providers" ; label="provider" ;;
2490
+ esac
2491
+ # Enabling means dropping the id from the exclusion list, disabling means
2492
+ # adding it — the API's remove/add endpoints, inverted here so the CLI reads
2493
+ # the way the dashboard's checkboxes do.
2494
+ doing="disabling"
2495
+ if [ "$action" = "enable" ]; then
2496
+ doing="enabling"
2497
+ path="$path/remove"
2498
+ fi
2499
+
2500
+ # Fed by a here-string, not a pipe: a pipe would run the loop in a subshell
2501
+ # where models_fail's exit only kills that subshell.
2502
+ while IFS= read -r id; do
2503
+ [ -n "$id" ] || continue
2504
+ body="$(jq -nc --arg v "$id" --arg k "$label" '{($k): $v}')"
2505
+ if ! models_api POST "$path" "$body"; then
2506
+ models_fail "$doing $label '$id'"
2507
+ fi
2508
+ # Under --json the router's own response body is the output: a scripted
2509
+ # caller parses stdout, and printing prose there makes it report a parse
2510
+ # failure for a mutation that already succeeded.
2511
+ if [ "$models_json" = "true" ]; then
2512
+ printf '%s\n' "$models_http_body"
2513
+ elif [ "$action" = "enable" ]; then
2514
+ printf "%s✓%s %s %s%s%s is enabled\n" "$C_GREEN" "$C_RESET" "$label" "$C_BOLD" "$id" "$C_RESET"
2515
+ else
2516
+ 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"
2517
+ fi
2518
+ done <<<"$ids"
2519
+ }
2520
+
2521
+ # models_prefer replaces the priority ranking outright (the API's PUT), because
2522
+ # a ranking is an ordering: appending one id at a time can't express "this one
2523
+ # first". `clear` drops it and returns the installation to plain routing.
2524
+ models_prefer() {
2525
+ local ids="$1" body stored
2526
+ if [ "$ids" = "clear" ] || [ "$ids" = "none" ]; then
2527
+ body='{"preferred":[]}'
2528
+ else
2529
+ body="$(printf '%s' "$ids" | jq -c -R -s '{preferred: (split("\n") | map(select(length > 0)))}')"
2530
+ fi
2531
+ models_api PUT "/admin/v1/preferred-models" "$body" || models_fail "setting the preferred-model ranking"
2532
+ if [ "$models_json" = "true" ]; then
2533
+ printf '%s\n' "$models_http_body"
2534
+ return 0
2535
+ fi
2536
+ stored="$(printf '%s' "$models_http_body" | jq -r '(.preferred // []) | join(" > ")')"
2537
+ if [ -n "$stored" ]; then
2538
+ printf "%s✓%s Preferred order: %s\n" "$C_GREEN" "$C_RESET" "$stored"
2539
+ else
2540
+ printf "%s✓%s Preferred-model ranking cleared.\n" "$C_GREEN" "$C_RESET"
2541
+ fi
2542
+ }
2543
+
2544
+ models_usage() {
2545
+ # Echo back the client the caller actually named, so a Codex user is never
2546
+ # told to run a command that would target (or create) a Claude Code install.
2547
+ local c="--${target}"
2548
+ err "$1"
2549
+ printf '%s\n' \
2550
+ " npx @workweave/router models $c # list models" \
2551
+ " npx @workweave/router models enable <id> [<id>…] $c" \
2552
+ " npx @workweave/router models disable <id> [<id>…] $c" \
2553
+ " npx @workweave/router models providers $c # list providers" \
2554
+ " npx @workweave/router models providers disable <name> $c" \
2555
+ " npx @workweave/router models prefer <id> [<id>…] $c # ranking ('clear' to drop)" >&2
2556
+ exit 2
2557
+ }
2558
+
2559
+ run_models() {
2560
+ local verb operands
2561
+ verb="$(printf '%s' "$models_args" | head -n 1)"
2562
+ operands="$(printf '%s' "$models_args" | tail -n +2)"
2563
+
2564
+ # `models providers …` shifts one more word: the sub-verb is the second word.
2565
+ case "$verb" in
2566
+ ""|list)
2567
+ [ -z "$operands" ] || models_usage "'models $verb' takes no arguments."
2568
+ models_list
2569
+ ;;
2570
+ enable|disable)
2571
+ [ -n "$operands" ] || models_usage "'models $verb' needs at least one model id."
2572
+ models_toggle model "$verb" "$operands"
2573
+ ;;
2574
+ prefer)
2575
+ [ -n "$operands" ] || models_usage "'models prefer' needs model ids, or 'clear'."
2576
+ models_prefer "$operands"
2577
+ ;;
2578
+ providers)
2579
+ local sub rest
2580
+ sub="$(printf '%s' "$operands" | head -n 1)"
2581
+ rest="$(printf '%s' "$operands" | tail -n +2)"
2582
+ case "$sub" in
2583
+ ""|list)
2584
+ models_providers_list
2585
+ ;;
2586
+ enable|disable)
2587
+ [ -n "$rest" ] || models_usage "'models providers $sub' needs at least one provider name."
2588
+ models_toggle provider "$sub" "$rest"
2589
+ ;;
2590
+ *)
2591
+ models_usage "Unknown providers sub-command: '$sub'."
2592
+ ;;
2593
+ esac
2594
+ ;;
2595
+ *)
2596
+ models_usage "Unknown models sub-command: '$verb'."
2597
+ ;;
2598
+ esac
2599
+ }
2600
+
2601
+ if [ "$mode" = "models" ]; then
2602
+ # Which file supplied the endpoint / the key. Empty means "not from a settings
2603
+ # file" — an explicit --base-url, or WEAVE_ROUTER_KEY. Initialized before the
2604
+ # branches below so `set -u` holds on every path.
2605
+ models_base_source=""
2606
+ models_key_source=""
2607
+ # Editing model selection needs the endpoint and key of one specific install,
2608
+ # never the hosted defaults: a self-hosted user pointing at their own router
2609
+ # would otherwise silently edit the hosted one's installation.
2610
+ if [ "$base_url_explicit" != "true" ]; then
2611
+ models_base="$(resolve_installed_endpoint)"
2612
+ if [ -z "$models_base" ]; then
2613
+ err "No Weave Router install found for $target in this scope. Run 'npx @workweave/router --$target' first, or pass --base-url."
2614
+ exit 1
2615
+ fi
2616
+ base_url="$models_base"
2617
+ # resolve_installed_endpoint ran in a command substitution, so any global it
2618
+ # set died with that subshell. Recover the source by walking the same
2619
+ # precedence to find which file holds the endpoint we just adopted. Only
2620
+ # Claude Code spreads its endpoint across several files; every other client
2621
+ # keeps endpoint and key in one managed config, which is self-consistent by
2622
+ # construction (see models_endpoint_is_trusted's same-file rule).
2623
+ if [ "$target" = "claude" ]; then
2624
+ while IFS= read -r models_src_candidate; do
2625
+ [ -n "$models_src_candidate" ] || continue
2626
+ models_src_url="$(json_get "$models_src_candidate" '.env.ANTHROPIC_BASE_URL')"
2627
+ if [ "${models_src_url%/}" = "$models_base" ]; then
2628
+ models_base_source="$models_src_candidate"
2629
+ break
2630
+ fi
2631
+ done <<<"$(models_base_file_order)"
2632
+ else
2633
+ models_base_source="$(models_config_file_for_target)"
2634
+ fi
2635
+ fi
2636
+ # WEAVE_ROUTER_KEY is the user's own choice, so it pairs with any endpoint.
2637
+ if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
2638
+ api_key="$WEAVE_ROUTER_KEY"
2639
+ models_key_source="env:WEAVE_ROUTER_KEY"
2640
+ else
2641
+ api_key="$(read_installed_key)"
2642
+ # Same subshell caveat as the endpoint above: recover which file the key
2643
+ # came from by re-reading them in read_installed_key's own precedence.
2644
+ models_key_source=""
2645
+ if [ "$target" = "claude" ]; then
2646
+ while IFS= read -r models_src_candidate; do
2647
+ [ -n "$models_src_candidate" ] || continue
2648
+ if [ -n "$(read_claude_key "$models_src_candidate")" ]; then
2649
+ models_key_source="$models_src_candidate"
2650
+ break
2651
+ fi
2652
+ done <<<"$(models_key_file_order)"
2653
+ else
2654
+ models_key_source="$(models_config_file_for_target)"
2655
+ fi
2656
+ fi
2657
+ if [ -z "$api_key" ]; then
2658
+ err "No router key found for $target in this scope. Re-run 'npx @workweave/router --$target', or export WEAVE_ROUTER_KEY."
2659
+ exit 1
2660
+ fi
2661
+ # Never send a key to an endpoint the checkout supplied. See
2662
+ # models_endpoint_is_trusted: a hostile repo can commit a settings.json naming
2663
+ # its own router, and pairing that with the teammate key from the gitignored
2664
+ # settings.local.json would hand the key to whoever wrote the repo.
2665
+ if [ "$models_key_source" != "env:WEAVE_ROUTER_KEY" ] \
2666
+ && ! models_endpoint_is_trusted "$base_url" "$models_base_source" "$models_key_source"; then
2667
+ err "Refusing to send this installation's router key to $base_url."
2668
+ printf " %sThat endpoint comes from %s, which git tracks — a checked-out repo can set it —%s\n" \
2669
+ "$C_DIM" "${models_base_source##*/}" "$C_RESET" >&2
2670
+ printf " %swhile the key comes from %s. Pass --base-url <url> to confirm the endpoint,%s\n" \
2671
+ "$C_DIM" "${models_key_source##*/}" "$C_RESET" >&2
2672
+ printf " %sor re-run 'npx @workweave/router --claude' to install against the one you want.%s\n" \
2673
+ "$C_DIM" "$C_RESET" >&2
2674
+ exit 1
2675
+ fi
2676
+ run_models
2677
+ exit 0
2678
+ fi
2679
+
1701
2680
  if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
1702
2681
  case "$target" in
1703
2682
  claude) toggle_claude ;;
@@ -1718,17 +2697,8 @@ fi
1718
2697
  # `off` parks the router URL and points Claude Code at Anthropic, so the parked
1719
2698
  # sidecar is the authority while toggled off — reading the live file there would
1720
2699
  # pin the install to api.anthropic.com.
1721
- if [ "$mode" = "update" ] && [ "$base_url_explicit" != "true" ] && [ "$target" = "claude" ]; then
1722
- installed_base=""
1723
- for candidate in \
1724
- "$settings_dir/.weave-parked.json" \
1725
- "$settings_file" \
1726
- "$local_settings_file"
1727
- do
1728
- installed_base="$(json_get "$candidate" '.env.ANTHROPIC_BASE_URL')"
1729
- router_shaped_url "$installed_base" && break
1730
- installed_base=""
1731
- done
2700
+ if [ "$mode" = "update" ] && [ "$base_url_explicit" != "true" ]; then
2701
+ installed_base="$(resolve_installed_endpoint)"
1732
2702
  if [ -n "$installed_base" ]; then
1733
2703
  base_url="${installed_base%/}"
1734
2704
  fi
@@ -1747,30 +2717,7 @@ fi
1747
2717
  # a re-run painless — the installer refreshes assets and config shape often
1748
2718
  # enough that users re-run it routinely, and re-pasting a key every time is pure
1749
2719
  # 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
- }
2720
+ # read_installed_key itself is defined above, next to the other settings readers.
1774
2721
 
1775
2722
  # prompt_for_key reads a key from the controlling terminal into $api_key. Only
1776
2723
  # ever called on an interactive install; callers check first.
@@ -1862,11 +2809,12 @@ fi
1862
2809
  # Claude: <settings_dir>/commands/{force-model,unforce-model}.md → /force-model
1863
2810
  # opencode: <commands_dir>/{force-model,unforce-model}.md → /force-model
1864
2811
  #
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.
2812
+ # Codex is intentionally not listed here: its slash menu only exposes built-in
2813
+ # commands and does not load these Markdown wrappers. Codex instead gets native
2814
+ # `$name` skills (see install_codex_prompt_skills), each of which sends the
2815
+ # leading-space prompt form (for example, ` /force-model gpt-5.6-terra`) that
2816
+ # the router parses. Which client gets which directive comes from the embedded
2817
+ # registry, not from a list here.
1870
2818
  #
1871
2819
  # Files come from install/commands/ in the repo (or the colocated commands/
1872
2820
  # directory the npm package ships alongside install.sh).
@@ -1893,13 +2841,22 @@ install_slash_commands() {
1893
2841
  # command-capable clients. The router-off/on/status wrappers shell out to
1894
2842
  # this installer to flip the *local* config, so they're Claude Code-only and
1895
2843
  # 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
2844
+ # at invocation time). {{SCOPE}} is substituted accordingly. router-models
2845
+ # (alias models) is in the same boat: it shells out to read this install's
2846
+ # endpoint and key.
2847
+ cmds=""
2848
+ while IFS= read -r cmd; do
2849
+ [ -f "$commands_src_dir/$cmd.md" ] || continue
2850
+ case ",$cmd," in
2851
+ *,router-off,*|*,router-on,*|*,router-status,*|*,router-session,*|*,router-models,*|*,models,*)
2852
+ [ "$target" = "claude" ] || continue ;;
2853
+ esac
2854
+ [ -n "$cmds" ] && cmds="$cmds "
2855
+ cmds="$cmds$cmd"
2856
+ done <<EOF
2857
+ $(weave_registry_names "$target")
2858
+ EOF
2859
+ installed="$(printf '%s' "$cmds" | tr ' ' ',' | sed 's/,/, /g')"
1903
2860
 
1904
2861
  # Bake the same scope selector the toggle needs to find this install: --dir
1905
2862
  # overrides scope (mirrors install/uninstall path resolution), so a sandbox
@@ -1923,7 +2880,21 @@ install_slash_commands() {
1923
2880
  # cp-equivalent for the others since the token is absent).
1924
2881
  local body; body="$(cat "$src")"
1925
2882
  body="${body//\{\{SCOPE\}\}/$scope_args}"
1926
- printf '%s\n' "$body" >"$dst"
2883
+ # Wrappers installed before ownership markers existed carry no marker. A
2884
+ # file whose body still matches what this installer would write is one of
2885
+ # ours from an older version, so adopt it rather than treating it as
2886
+ # user-owned — otherwise every pre-marker install is stranded: never
2887
+ # refreshed, never uninstalled, and skipped by the statusline refresh.
2888
+ # Anything that does not match byte-for-byte is left alone.
2889
+ if [ -e "$dst" ] && { [ ! -f "$dst" ] || ! grep -Fq "<!-- weave-router managed command: $cmd -->" "$dst"; }; then
2890
+ if [ -f "$dst" ] && [ "$(cat "$dst")" = "$body" ]; then
2891
+ : # an unmarked copy of our own wrapper; fall through and adopt it
2892
+ else
2893
+ warn "A user-owned $target command already exists at $dst; leaving it untouched."
2894
+ continue
2895
+ fi
2896
+ fi
2897
+ printf '%s\n<!-- weave-router managed command: %s -->' "$body" "$cmd" >"$dst"
1927
2898
  done
1928
2899
  seed_command_baseline "$commands_src_dir" "$cmds"
1929
2900
  ok "Slash commands written to $dst_dir ($installed)"
@@ -1984,77 +2955,541 @@ remove_obsolete_codex_prompt_wrappers() {
1984
2955
  refuse_if_symlink "$dst_dir"
1985
2956
  fi
1986
2957
 
1987
- for cmd in force-model unforce-model router-feedback fm ufm rf; do
2958
+ while IFS= read -r cmd; do
1988
2959
  src="$commands_src_dir/$cmd.md"
1989
2960
  dst="$dst_dir/$cmd.md"
1990
2961
  [ -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
2962
  [ -L "$dst" ] && continue
1994
2963
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1995
2964
  refuse_if_symlink "$dst"
1996
2965
  fi
1997
2966
  cmp -s "$src" "$dst" && rm -f "$dst"
1998
- done
2967
+ done <<EOF
2968
+ $(weave_registry_names codex)
2969
+ EOF
1999
2970
  rmdir "$dst_dir" 2>/dev/null || true
2000
2971
  }
2001
2972
 
2002
- # Codex skills are the supported local extension surface for an action that
2003
- # must edit Codex's configuration. A literal third-party slash command cannot
2004
- # do this because Codex reserves `/…` for built-ins. The skill is invoked as
2005
- # `$disable-routing`, asks Codex to run the existing safe toggle, and leaves
2006
- # the managed router block in place for a later re-enable.
2007
- install_codex_disable_routing_skill() {
2008
- local skill_src=""
2009
- local candidate dst_dir dst_file scope_args body
2010
- for candidate in \
2011
- "$script_dir/codex-skills/disable-routing/SKILL.md" \
2012
- "$script_dir/../codex-skills/disable-routing/SKILL.md"
2013
- do
2014
- if [ -f "$candidate" ]; then
2973
+
2974
+ # Install every Codex-native skill the registry declares for this client:
2975
+ # prompt directives, their aliases, and local-config toggles like $router-off
2976
+ # that shell out to this installer's own verbs. weave_registry_skill_assets is
2977
+ # the union, canonical names plus aliases Codex discovers skills by directory
2978
+ # name, so an advertised $fm needs its own installed skill rather than a
2979
+ # pointer to $force-model.
2980
+ #
2981
+ # A name with no SKILL.md is skipped: an alias may exist for the Claude command
2982
+ # surface without a Codex skill behind it (registry_test.sh pins which ones do).
2983
+ install_codex_prompt_skills() {
2984
+ local canonical candidate skill_src dst_dir dst_file emit_src emit_dst scope_args body
2985
+ while IFS= read -r canonical; do
2986
+ skill_src=""
2987
+ for candidate in \
2988
+ "$script_dir/codex-skills/$canonical/SKILL.md" \
2989
+ "$script_dir/../codex-skills/$canonical/SKILL.md"
2990
+ do
2991
+ [ -f "$candidate" ] || continue
2015
2992
  skill_src="$candidate"
2016
2993
  break
2994
+ done
2995
+ [ -n "$skill_src" ] || continue
2996
+ grep -Fq "<!-- weave-router managed $canonical skill -->" "$skill_src" || continue
2997
+ dst_dir="$codex_dir/skills/$canonical"
2998
+ dst_file="$dst_dir/SKILL.md"
2999
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
3000
+ refuse_if_symlink "$codex_dir/skills"
3001
+ refuse_if_symlink "$dst_dir"
3002
+ refuse_if_symlink "$dst_file"
3003
+ elif [ -L "$codex_dir/skills" ] || [ -L "$dst_dir" ] || [ -L "$dst_file" ]; then
3004
+ warn "Codex skill path contains a symlink; leaving $canonical untouched."
3005
+ continue
2017
3006
  fi
2018
- done
2019
- [ -n "$skill_src" ] || { warn "Codex disable-routing skill template is missing; router configuration is still installed."; return 0; }
2020
- grep -Fq "$WEAVE_CODEX_SKILL_MARKER" "$skill_src" \
2021
- || { warn "Codex disable-routing skill template has no ownership marker; leaving skills unchanged."; return 0; }
3007
+ if [ -e "$dst_file" ] && { [ ! -f "$dst_file" ] || ! grep -Fq "<!-- weave-router managed $canonical skill -->" "$dst_file"; }; then
3008
+ warn "A user-owned Codex $canonical skill already exists at $dst_file; leaving it untouched."
3009
+ continue
3010
+ fi
3011
+ mkdir -p "$dst_dir"
3012
+ scope_args=""
3013
+ if [ -n "$install_dir" ]; then scope_args=" --dir $(printf '%q' "$install_dir")"
3014
+ elif [ "$scope" = project ]; then scope_args=" --scope project"; fi
3015
+ body="$(<"$skill_src")"
3016
+ body="${body//\{\{SCOPE\}\}/$scope_args}"
3017
+ printf '%s\n' "$body" >"$dst_file"
3018
+ # Prompt skills emit their directive through a script Codex execs; toggles
3019
+ # shell out to the installer's own verbs and ship none.
3020
+ emit_src="${skill_src%/SKILL.md}/scripts/emit.sh"
3021
+ if [ -f "$emit_src" ]; then
3022
+ mkdir -p "$dst_dir/scripts"
3023
+ emit_dst="$dst_dir/scripts/emit.sh"
3024
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
3025
+ refuse_if_symlink "$dst_dir/scripts"
3026
+ refuse_if_symlink "$emit_dst"
3027
+ elif [ -L "$dst_dir/scripts" ] || [ -L "$emit_dst" ]; then
3028
+ warn "Codex skill script path contains a symlink; leaving $canonical emit.sh untouched."
3029
+ else
3030
+ cp "$emit_src" "$emit_dst"
3031
+ chmod +x "$emit_dst"
3032
+ fi
3033
+ fi
3034
+ ok "Codex skill installed: \$$canonical"
3035
+ done <<EOF
3036
+ $(weave_registry_skill_assets codex)
3037
+ EOF
3038
+ }
2022
3039
 
2023
- dst_dir="$codex_dir/skills/disable-routing"
2024
- dst_file="$dst_dir/SKILL.md"
2025
- if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
2026
- refuse_if_symlink "$codex_dir/skills"
2027
- refuse_if_symlink "$dst_dir"
2028
- refuse_if_symlink "$dst_file"
2029
- elif [ -L "$codex_dir/skills" ] || [ -L "$dst_dir" ] || [ -L "$dst_file" ]; then
2030
- warn "Codex disable-routing skill path contains a symlink; leaving it untouched."
2031
- return 0
3040
+ # ---------- post-install verification (shared by every target) ----------
3041
+ #
3042
+ # Every client gets the same two probes reach the router, then prove the key
3043
+ # it was just handed actually authenticates — so a working install produces the
3044
+ # same green checks regardless of which one was installed.
3045
+
3046
+ # validate_key asks the router whether $api_key is live. The key goes over stdin
3047
+ # (`@-`) rather than a -H argument so it never appears in the process arg list,
3048
+ # where `ps` / /proc would expose it to other local users on a shared machine.
3049
+ # Wrapped in a function so the spinner's exec form sees a single command argv.
3050
+ validate_key() {
3051
+ printf '%s: %s\n' "$router_key_header" "$api_key" \
3052
+ | curl -fsS --max-time 5 --header @- "$base_url/validate"
3053
+ }
3054
+
3055
+ # rewrite_installed_key rewrites this target's managed config with the key in $1.
3056
+ # Used by the rejected-key fallback below to swap in a replacement without
3057
+ # re-running the whole install.
3058
+ rewrite_installed_key() {
3059
+ local key="$1"
3060
+ case "$target" in
3061
+ claude) write_claude_settings "$key" ;;
3062
+ codex) write_codex_config "$codex_config_file" "$base_url" "$key" "$user_email" "$user_name" ;;
3063
+ opencode) write_opencode_config "$opencode_config_file" "$base_url" "$key" "$user_email" "$user_name" ;;
3064
+ pi)
3065
+ write_pi_models_config "$pi_models_file" "$base_url" "$key" "$user_email" "$user_name"
3066
+ printf '%s\n' "$key" >"$pi_key_file"
3067
+ chmod 600 "$pi_key_file"
3068
+ ;;
3069
+ esac
3070
+ }
3071
+
3072
+ verify_install() {
3073
+ if [ "$quiet" != "true" ]; then
3074
+ if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
3075
+ warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
3076
+ fi
2032
3077
  fi
2033
- if [ -e "$dst_file" ] && { [ ! -f "$dst_file" ] || ! grep -Fq "$WEAVE_CODEX_SKILL_MARKER" "$dst_file"; }; then
2034
- warn "A user-owned Codex disable-routing skill already exists at $dst_file; leaving it untouched."
3078
+
3079
+ [ -n "$api_key" ] || return 0
3080
+
3081
+ spin "Validating API key" validate_key && return 0
3082
+
3083
+ # A key we read back off disk can have been revoked or rotated since it was
3084
+ # installed, and reusing it silently would leave a broken install behind
3085
+ # exactly where the old behavior (always prompt) would have fixed it. Ask
3086
+ # once, then rewrite the config with whatever the user pastes. Anywhere a
3087
+ # prompt isn't available — non-interactive, update, --quiet, no tty, or a
3088
+ # key that didn't come from disk — keep the historical warn-and-continue.
3089
+ if [ "$api_key_source" = "disk" ] && [ "$non_interactive" != "true" ] \
3090
+ && [ "$quiet" != "true" ] && [ -r /dev/tty ]; then
3091
+ warn "The router key already installed was rejected (revoked or rotated)."
3092
+ prompt_for_key
3093
+ rewrite_installed_key "$api_key"
3094
+ if ! spin "Validating API key" validate_key; then
3095
+ warn "Router rejected the API key (check it matches the dashboard at $base_url)."
3096
+ fi
2035
3097
  return 0
2036
3098
  fi
2037
- mkdir -p "$dst_dir"
2038
3099
 
2039
- scope_args=""
2040
- if [ -n "$install_dir" ]; then
2041
- scope_args=" --dir $(printf '%q' "$install_dir")"
2042
- elif [ "$scope" = "project" ]; then
2043
- scope_args=" --scope project"
3100
+ if [ "$mode" = "update" ]; then
3101
+ # update is meant for cron/scripting: a rejected key is a real failure,
3102
+ # not a note in the log. --quiet callers opted out of the noise, not out
3103
+ # of the nonzero exit a scheduled run that reports success here would
3104
+ # hide a revoked key indefinitely.
3105
+ if [ "$quiet" = "true" ]; then
3106
+ warn "Router rejected the installed API key. Re-run the installer to set a new one."
3107
+ else
3108
+ err "Router rejected the installed API key. Re-run the installer to set a new one."
3109
+ fi
3110
+ exit 1
2044
3111
  fi
2045
- body="$(<"$skill_src")"
2046
- body="${body//\{\{SCOPE\}\}/$scope_args}"
2047
- printf '%s\n' "$body" >"$dst_file"
2048
- ok "Codex skill installed: \$disable-routing"
3112
+
3113
+ warn "Router rejected the API key (check it matches the dashboard at $base_url)."
3114
+ }
3115
+
3116
+ # announce_done prints the closing line for the client named in $1. `update`
3117
+ # refreshed an existing install rather than creating one, so it says so and
3118
+ # skips the uninstall hint — that run was never the user's first contact with
3119
+ # the installer.
3120
+ announce_done() {
3121
+ printf "\n"
3122
+ if [ "$mode" = "update" ]; then
3123
+ printf "%s✓%s %s%sWeave Router config refreshed for %s.%s\n" \
3124
+ "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$1" "$C_RESET"
3125
+ return 0
3126
+ fi
3127
+ printf "%s✓%s %s%sWeave Router installed for %s.%s\n" \
3128
+ "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$1" "$C_RESET"
2049
3129
  }
2050
3130
 
2051
3131
  # ---------- codex install path (dispatch + exit before the Claude-only writes) ----------
2052
3132
 
3133
+ # Install the Codex lifecycle helper that reflects the active router and the
3134
+ # latest known routed model in the terminal title. The embedded copy keeps the
3135
+ # standalone curl installer feature-complete; packaged installs use the
3136
+ # canonical sibling asset.
3137
+ install_codex_status_script() {
3138
+ local candidate status_src=""
3139
+ for candidate in \
3140
+ "$script_dir/codex-status.sh" \
3141
+ "$script_dir/../codex-status.sh"
3142
+ do
3143
+ if [ -f "$candidate" ]; then
3144
+ status_src="$candidate"
3145
+ break
3146
+ fi
3147
+ done
3148
+ if [ "$scope" = "user" ] && [ -z "$install_dir" ]; then
3149
+ mkdir -p "$(dirname "$codex_status_file")"
3150
+ else
3151
+ refuse_if_symlink "$codex_status_file"
3152
+ fi
3153
+ if [ -e "$codex_status_file" ] && { [ ! -f "$codex_status_file" ] || ! grep -Fq '<!-- weave-router managed codex status -->' "$codex_status_file"; }; then
3154
+ warn "A user-owned Codex status helper already exists at $codex_status_file; leaving it untouched."
3155
+ return 1
3156
+ fi
3157
+ if [ -n "$status_src" ]; then
3158
+ grep -Fq '<!-- weave-router managed codex status -->' "$status_src" || {
3159
+ warn "Codex status helper has no ownership marker; leaving it unchanged."
3160
+ return 1
3161
+ }
3162
+ cp "$status_src" "$codex_status_file"
3163
+ else
3164
+ cat >"$codex_status_file" <<'CODEX_STATUS_EOF'
3165
+ #!/usr/bin/env bash
3166
+ # <!-- weave-router managed codex status -->
3167
+ #
3168
+ # Codex lifecycle hook for the Weave Router. Codex passes a JSON object on
3169
+ # stdin; the Stop hook includes the last assistant message, which carries the
3170
+ # router's routed-model marker when the selected model changes. The helper
3171
+ # keeps the last known routed model per session and reflects it in the terminal
3172
+ # title, so the active router remains visible between turns without injecting
3173
+ # another message into the conversation.
3174
+ #
3175
+ # Savings come from the router, not from local arithmetic. Codex records its
3176
+ # own requested model on every turn and never the served one, so the per-turn
3177
+ # pricing the Claude Code statusline does cannot be reproduced here — it would
3178
+ # price both sides of the comparison at the same model and report zero. The
3179
+ # router already sums the real thing per session, so the hook fetches
3180
+ # GET <base>/v1/sessions/<id>/cost and renders what it returns. The fetch runs
3181
+ # in a detached subshell writing a cache the NEXT turn reads, so no turn ever
3182
+ # blocks on the network, and every failure path leaves the title model-only.
3183
+
3184
+ set -euo pipefail
3185
+
3186
+ state_root="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router/codex"
3187
+ helper_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)"
3188
+ disabled_marker="$helper_dir/.weave-router-disabled"
3189
+ router_badge_sentinel=$'⁣⁠⁣⁠'
3190
+ # Must stay verbatim in sync with install.sh / uninstall.sh: the endpoint read
3191
+ # below is scoped to this block so a key-shaped string elsewhere in the user's
3192
+ # config.toml is never adopted.
3193
+ codex_begin_marker="# >>> weave-router managed (do not edit between markers) >>>"
3194
+ codex_end_marker="# <<< weave-router managed <<<"
3195
+
3196
+ emit_title() {
3197
+ local title="$1"
3198
+ if [ -n "${WEAVE_CODEX_STATUS_TITLE_FILE:-}" ]; then
3199
+ printf '%s\n' "$title" >"$WEAVE_CODEX_STATUS_TITLE_FILE"
3200
+ elif [ -w /dev/tty ]; then
3201
+ printf '\033]0;%s\007' "$title" >/dev/tty
3202
+ fi
3203
+ }
3204
+
3205
+ safe_session_id() {
3206
+ local id="$1"
3207
+ case "$id" in
3208
+ ''|*[!A-Za-z0-9._-]*) return 1 ;;
3209
+ esac
3210
+ [ "${#id}" -le 128 ] || return 1
3211
+ printf '%s' "$id"
3212
+ }
3213
+
3214
+ safe_display_value() {
3215
+ printf '%s' "$1" | sed 's/[^A-Za-z0-9._:\/-]//g' | cut -c1-128
3216
+ }
3217
+
3218
+ state_file_for() {
3219
+ local id
3220
+ id="$(safe_session_id "$1")" || return 1
3221
+ printf '%s/%s.state' "$state_root" "$id"
3222
+ }
3223
+
3224
+ read_state() {
3225
+ local file="$1" key value
3226
+ [ -f "$file" ] || return 0
3227
+ while IFS='=' read -r key value; do
3228
+ case "$key" in
3229
+ routed_model) routed_model="$value" ;;
3230
+ esac
3231
+ done <"$file"
3232
+ }
3233
+
3234
+ write_state() {
3235
+ local file="$1" tmp
3236
+ mkdir -p "$state_root"
3237
+ chmod 700 "$state_root"
3238
+ tmp="$(mktemp "$state_root/.state.XXXXXX")"
3239
+ printf 'requested_model=%s\nrouted_model=%s\n' "$requested_model" "$routed_model" >"$tmp"
3240
+ chmod 600 "$tmp"
3241
+ mv "$tmp" "$file"
3242
+ }
3243
+
3244
+ cost_file_for() {
3245
+ local id
3246
+ id="$(safe_session_id "$1")" || return 1
3247
+ printf '%s/%s.cost' "$state_root" "$id"
3248
+ }
3249
+
3250
+ # Reads the router base URL and key out of the Codex config this install owns.
3251
+ # Resolved from the helper's own location first so a project-scope install never
3252
+ # reads (or leaks) the user-scope key: the project helper lives in the same
3253
+ # .codex directory as its config, while the user-scope helper sits in ~/.weave
3254
+ # and reads ~/.codex. Values are scoped to the managed block so a key-shaped
3255
+ # string the user wrote elsewhere in the file is never adopted. awk, not a TOML
3256
+ # parser, because the Codex target deliberately does not require jq for config
3257
+ # reads.
3258
+ read_codex_endpoint() {
3259
+ local config=""
3260
+ # Project/custom installs name the helper weave-status.sh and keep it next
3261
+ # to their config.toml. Falling through to ~/.codex would fetch another
3262
+ # installation's session cost with that key. The user-scope helper
3263
+ # (codex-status.sh in ~/.weave) has no adjacent config and owns HOME.
3264
+ if [ -f "$helper_dir/config.toml" ]; then
3265
+ config="$helper_dir/config.toml"
3266
+ elif [ "$(basename "$0")" != "weave-status.sh" ] && [ -f "$HOME/.codex/config.toml" ]; then
3267
+ config="$HOME/.codex/config.toml"
3268
+ fi
3269
+ [ -n "$config" ] || return 0
3270
+ awk -v begin="$codex_begin_marker" -v end="$codex_end_marker" '
3271
+ $0 == begin { inblk = 1; next }
3272
+ $0 == end { inblk = 0; next }
3273
+ !inblk { next }
3274
+ match($0, /base_url[[:space:]]*=[[:space:]]*"[^"]*"/) {
3275
+ v = substr($0, RSTART, RLENGTH)
3276
+ sub(/^.*=[[:space:]]*"/, "", v); sub(/"$/, "", v)
3277
+ url = v
3278
+ }
3279
+ match($0, /"X-Weave-Router-Key"[[:space:]]*=[[:space:]]*"[^"]*"/) {
3280
+ v = substr($0, RSTART, RLENGTH)
3281
+ sub(/^.*=[[:space:]]*"/, "", v); sub(/"$/, "", v)
3282
+ key = v
3283
+ }
3284
+ END { if (url != "" && key != "") printf "%s\n%s\n", url, key }
3285
+ ' "$config" 2>/dev/null || true
3286
+ }
3287
+
3288
+ # Kicks off a detached fetch of this session's committed cost. The result lands
3289
+ # in a cache the next turn reads; this turn renders whatever is already there.
3290
+ # Fire-and-forget on purpose — a slow or unreachable router must never stall a
3291
+ # Codex turn, and every failure simply leaves the previous cache in place.
3292
+ refresh_session_cost() {
3293
+ local id="$1" file="$2"
3294
+ [ "${WEAVE_CODEX_STATUS_SAVINGS:-1}" = "0" ] && return 0
3295
+ command -v curl >/dev/null 2>&1 || return 0
3296
+
3297
+ local endpoint base_url key
3298
+ endpoint="$(read_codex_endpoint)" || return 0
3299
+ base_url="$(printf '%s' "$endpoint" | sed -n 1p)"
3300
+ key="$(printf '%s' "$endpoint" | sed -n 2p)"
3301
+ [ -n "$base_url" ] && [ -n "$key" ] || return 0
3302
+
3303
+ mkdir -p "$state_root" 2>/dev/null || return 0
3304
+ chmod 700 "$state_root" 2>/dev/null || true
3305
+
3306
+ (
3307
+ exec </dev/null
3308
+ # mkdir is the portable atomic test-and-set. A crashed holder would block
3309
+ # refreshes forever, so a lock older than the fetch timeout is reclaimed.
3310
+ lock="$file.lock"
3311
+ if ! mkdir "$lock" 2>/dev/null; then
3312
+ lock_mtime="$(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock" 2>/dev/null)" || lock_mtime=0
3313
+ lock_now="$(date +%s 2>/dev/null)" || lock_now=0
3314
+ if [ "${lock_mtime:-0}" -le 0 ] || [ $(( lock_now - lock_mtime )) -le 30 ]; then
3315
+ exit 0
3316
+ fi
3317
+ rm -rf "$lock" 2>/dev/null
3318
+ mkdir "$lock" 2>/dev/null || exit 0
3319
+ fi
3320
+ trap 'rmdir "$lock" 2>/dev/null' EXIT
3321
+
3322
+ # A file:// base is the offline/test seam: curl reads it as the response
3323
+ # body directly, so the endpoint path is meaningless for it.
3324
+ url="${base_url%/}"
3325
+ case "$url" in
3326
+ file://*) ;;
3327
+ *) url="${url%/v1}/v1/sessions/$id/cost" ;;
3328
+ esac
3329
+ body="$(curl -fsS --max-time 5 -H "X-Weave-Router-Key: $key" "$url" 2>/dev/null)" || exit 0
3330
+ # savings_usd is the router's own (requested - actual). A body without it
3331
+ # (404, error envelope, older router) writes nothing and leaves the cache.
3332
+ savings="$(printf '%s' "$body" | jq -r '.savings_usd // empty' 2>/dev/null)" || exit 0
3333
+ case "$savings" in
3334
+ ''|*[!0-9.eE+-]*) exit 0 ;;
3335
+ esac
3336
+ tmp="$file.tmp.$$"
3337
+ mkdir -p "$(dirname "$file")" 2>/dev/null
3338
+ if printf '%s' "$savings" >"$tmp" 2>/dev/null; then
3339
+ chmod 600 "$tmp" 2>/dev/null
3340
+ mv "$tmp" "$file" 2>/dev/null
3341
+ fi
3342
+ rm -f "$tmp" 2>/dev/null
3343
+ ) >/dev/null 2>&1 &
3344
+ disown 2>/dev/null || true
3345
+ }
3346
+
3347
+ # Renders the cached savings as a display clause, or nothing. Values below a
3348
+ # cent read as "<$0.01" rather than "$0.00", which would be indistinguishable
3349
+ # from "the router ran and did not beat your selection". Negative totals are
3350
+ # omitted entirely: the router picked a pricier model for quality on this
3351
+ # session and "saved -$0.02" is a worse answer than staying quiet.
3352
+ savings_clause() {
3353
+ local file="$1" raw
3354
+ [ "${WEAVE_CODEX_STATUS_SAVINGS:-1}" = "0" ] && return 0
3355
+ [ -f "$file" ] || return 0
3356
+ raw="$(cat "$file" 2>/dev/null)" || return 0
3357
+ case "$raw" in
3358
+ ''|*[!0-9.eE+-]*) return 0 ;;
3359
+ esac
3360
+ awk -v v="$raw" 'BEGIN{
3361
+ v = v + 0
3362
+ if (v < 0.005) { exit }
3363
+ if (v < 0.01) { printf " · saved <$0.01"; exit }
3364
+ printf " · saved $%.2f", v
3365
+ }' 2>/dev/null || true
3366
+ }
3367
+
3368
+ set -e
3369
+
3370
+ case "${1:-hook}" in
3371
+ --direct)
3372
+ emit_title "Codex · direct"
3373
+ exit 0
3374
+ ;;
3375
+ --on)
3376
+ rm -f "$disabled_marker"
3377
+ emit_title "Weave Router · active"
3378
+ exit 0
3379
+ ;;
3380
+ --off)
3381
+ [ ! -L "$disabled_marker" ] || exit 0
3382
+ : >"$disabled_marker"
3383
+ chmod 600 "$disabled_marker"
3384
+ emit_title "Codex · direct"
3385
+ exit 0
3386
+ ;;
3387
+ esac
3388
+
3389
+ payload="$(cat)"
3390
+ [ -n "$payload" ] || exit 0
3391
+ command -v jq >/dev/null 2>&1 || exit 0
3392
+ jq -e . >/dev/null 2>&1 <<<"$payload" || exit 0
3393
+
3394
+ hook_event_name="$(jq -r '.hook_event_name // ""' <<<"$payload")"
3395
+ if [ "$hook_event_name" = "SessionStart" ]; then
3396
+ if [ -f "$disabled_marker" ]; then
3397
+ emit_title "Codex · direct"
3398
+ jq -cn '{systemMessage:"Codex direct · Weave Router is off"}'
3399
+ else
3400
+ emit_title "Weave Router · active"
3401
+ jq -cn '{systemMessage:"Weave Router active · routed model appears in the terminal title"}'
3402
+ fi
3403
+ exit 0
3404
+ fi
3405
+
3406
+ if [ -f "$disabled_marker" ]; then
3407
+ exit 0
3408
+ fi
3409
+
3410
+ requested_model="$(safe_display_value "$(jq -r '.model // ""' <<<"$payload")")"
3411
+ routed_model=""
3412
+ session_id="$(jq -r '.session_id // ""' <<<"$payload")"
3413
+ last_assistant_message="$(jq -r '.last_assistant_message // ""' <<<"$payload")"
3414
+
3415
+ file=""
3416
+ if file="$(state_file_for "$session_id" 2>/dev/null)"; then
3417
+ read_state "$file"
3418
+ fi
3419
+
3420
+ # The marker is intentionally matched only at the beginning of the assistant
3421
+ # message. Do not treat ordinary prose that mentions the heading as metadata.
3422
+ first_line="${last_assistant_message%%$'\n'*}"
3423
+ marker_model=""
3424
+ force_model=""
3425
+ case "$first_line" in
3426
+ "${router_badge_sentinel}✦ **Weave Router** → "*)
3427
+ marker_model="${first_line#"${router_badge_sentinel}✦ **Weave Router** → "}"
3428
+ marker_model="${marker_model%% ·*}"
3429
+ ;;
3430
+ "✦ **Weave Router** → "*)
3431
+ marker_model="${first_line#"✦ **Weave Router** → "}"
3432
+ marker_model="${marker_model%% ·*}"
3433
+ ;;
3434
+ esac
3435
+ case "$first_line" in
3436
+ "Weave Router: force-model applied: "*" ("*)
3437
+ force_model="${first_line#"Weave Router: force-model applied: "}"
3438
+ force_model="${force_model%% (*}"
3439
+ ;;
3440
+ esac
3441
+ if [ -n "$marker_model" ]; then
3442
+ routed_model="$(safe_display_value "$marker_model")"
3443
+ elif [ -n "$force_model" ]; then
3444
+ routed_model="$(safe_display_value "$force_model")"
3445
+ else
3446
+ routed_model="$(safe_display_value "$routed_model")"
3447
+ fi
3448
+
3449
+ if [ -n "$file" ]; then
3450
+ write_state "$file"
3451
+ fi
3452
+
3453
+ # The cache holds the previous turn's fetch; the refresh below serves the next
3454
+ # one. Router telemetry is written asynchronously anyway, so a just-finished
3455
+ # turn would not be included even in a blocking read — reading first and
3456
+ # refreshing after costs a turn of freshness and buys never blocking Codex.
3457
+ savings=""
3458
+ cost_file=""
3459
+ if cost_file="$(cost_file_for "$session_id" 2>/dev/null)"; then
3460
+ savings="$(savings_clause "$cost_file")"
3461
+ refresh_session_cost "$(safe_session_id "$session_id")" "$cost_file"
3462
+ fi
3463
+
3464
+ if [ -n "$routed_model" ] && [ -n "$requested_model" ] && [ "$routed_model" != "$requested_model" ]; then
3465
+ title="Weave Router · $routed_model ← $requested_model$savings"
3466
+ elif [ -n "$routed_model" ]; then
3467
+ title="Weave Router · $routed_model$savings"
3468
+ elif [ -n "$requested_model" ]; then
3469
+ title="Weave Router · active ← $requested_model$savings"
3470
+ else
3471
+ title="Weave Router · active$savings"
3472
+ fi
3473
+ emit_title "$title"
3474
+ if [ -n "$marker_model" ] || [ -n "$force_model" ]; then
3475
+ printf '%s' "$title" | jq -Rc '{systemMessage: .}'
3476
+ fi
3477
+ CODEX_STATUS_EOF
3478
+ fi
3479
+ chmod 700 "$codex_status_file"
3480
+ "$codex_status_file" --on >/dev/null 2>&1 || true
3481
+ ok "Codex status integration installed at $codex_status_file"
3482
+ }
3483
+
2053
3484
  if [ "$target" = "codex" ]; then
3485
+ if ! install_codex_status_script; then
3486
+ err "Cannot install the Codex status helper safely; refusing to write hooks that could execute unowned code."
3487
+ exit 1
3488
+ fi
2054
3489
  write_codex_config "$codex_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
2055
3490
  ok "Codex config written to $codex_config_file"
2056
3491
  remove_obsolete_codex_prompt_wrappers "$codex_dir/prompts"
2057
- install_codex_disable_routing_skill
3492
+ install_codex_prompt_skills
2058
3493
  info "Codex router directives: begin the message with one space, e.g. ' /force-model gpt-5.6-terra'."
2059
3494
 
2060
3495
  # Project scope: ensure the per-teammate config (which holds the router key)
@@ -2065,44 +3500,26 @@ if [ "$target" = "codex" ]; then
2065
3500
  gitignore="$git_root/.gitignore"
2066
3501
  refuse_if_symlink "$gitignore"
2067
3502
  for entry in \
2068
- ".codex/config.toml"
3503
+ ".codex/config.toml" \
3504
+ ".codex/weave-status.sh" \
3505
+ ".codex/.weave-router-disabled"
2069
3506
  do
2070
3507
  if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
2071
3508
  printf '%s\n' "$entry" >>"$gitignore"
2072
3509
  fi
2073
3510
  done
2074
- ok "Updated $gitignore (ignored .codex/config.toml)"
2075
- fi
2076
-
2077
- # Post-install verification: same probes the Claude path runs so a working
2078
- # install gives the same green checks regardless of target.
2079
- if [ "$quiet" != "true" ]; then
2080
- if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
2081
- warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
2082
- fi
3511
+ ok "Updated $gitignore (ignored Codex router config and status helper)"
2083
3512
  fi
2084
3513
 
2085
- if [ -n "$api_key" ]; then
2086
- # Pass the router key via stdin (`@-`) instead of -H so it never lands in
2087
- # the process arg list. Mirrors the Claude-path validate logic.
2088
- validate_codex_key() {
2089
- printf '%s: %s\n' "$router_key_header" "$api_key" \
2090
- | curl -fsS --max-time 5 --header @- "$base_url/validate"
2091
- }
2092
- if ! spin "Validating API key" validate_codex_key; then
2093
- warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
2094
- fi
2095
- fi
3514
+ verify_install
2096
3515
 
2097
- printf "\n"
2098
- printf "%s✓%s %s%sWeave Router installed for Codex.%s\n" \
2099
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
3516
+ announce_done "Codex"
2100
3517
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
2101
3518
  # Codex auto-discovers ~/.codex; for non-user installs the caller has to
2102
3519
  # point CODEX_HOME at the directory we wrote so Codex finds our config.
2103
3520
  info "Run Codex with CODEX_HOME=$codex_dir codex so it picks up this config."
2104
3521
  fi
2105
- print_uninstall_hint
3522
+ [ "$mode" = "update" ] || print_uninstall_hint
2106
3523
  exit 0
2107
3524
  fi
2108
3525
 
@@ -2150,26 +3567,9 @@ if [ "$target" = "opencode" ]; then
2150
3567
  info "opencode restart required for commands to take effect."
2151
3568
  fi
2152
3569
 
2153
- # Post-install verification: same probes the Claude/Codex paths run.
2154
- if [ "$quiet" != "true" ]; then
2155
- if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
2156
- warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
2157
- fi
2158
- fi
3570
+ verify_install
2159
3571
 
2160
- if [ -n "$api_key" ]; then
2161
- validate_opencode_key() {
2162
- printf '%s: %s\n' "$router_key_header" "$api_key" \
2163
- | curl -fsS --max-time 5 --header @- "$base_url/validate"
2164
- }
2165
- if ! spin "Validating API key" validate_opencode_key; then
2166
- warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
2167
- fi
2168
- fi
2169
-
2170
- printf "\n"
2171
- printf "%s✓%s %s%sWeave Router installed for opencode.%s\n" \
2172
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
3572
+ announce_done "opencode"
2173
3573
  # Surface the optional subscription-routing path only when this run actually
2174
3574
  # registered the weave-claude login provider (which is written together with
2175
3575
  # the plugin). Gate on its presence in the written config (authoritative)
@@ -2184,7 +3584,7 @@ if [ "$target" = "opencode" ]; then
2184
3584
  # has to point opencode at the file explicitly.
2185
3585
  info "Run opencode with OPENCODE_CONFIG=$opencode_config_file opencode."
2186
3586
  fi
2187
- print_uninstall_hint
3587
+ [ "$mode" = "update" ] || print_uninstall_hint
2188
3588
  exit 0
2189
3589
  fi
2190
3590
 
@@ -2225,26 +3625,13 @@ if [ "$target" = "pi" ]; then
2225
3625
  ok "Updated $gitignore (ignored repo-local .pi router config)"
2226
3626
  fi
2227
3627
 
2228
- # Post-install verification: same probes the Claude/Codex/opencode paths run.
2229
- if [ "$quiet" != "true" ]; then
2230
- if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
2231
- warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
2232
- fi
2233
- fi
3628
+ verify_install
2234
3629
 
2235
- if [ -n "$api_key" ]; then
2236
- validate_pi_key() {
2237
- printf '%s: %s\n' "$router_key_header" "$api_key" \
2238
- | curl -fsS --max-time 5 --header @- "$base_url/validate"
2239
- }
2240
- if ! spin "Validating API key" validate_pi_key; then
2241
- warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
2242
- fi
3630
+ if [ -n "$lsp_langs" ]; then
3631
+ install_lsp_servers "$lsp_langs"
2243
3632
  fi
2244
3633
 
2245
- printf "\n"
2246
- printf "%s✓%s %s%sWeave Router installed for pi.%s\n" \
2247
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
3634
+ announce_done "pi"
2248
3635
  # Billing note: pi normally draws on a Claude subscription (OAuth); routing
2249
3636
  # through the router switches to per-token billing on the router deployment
2250
3637
  # key (or BYOK). Surface it at install so the change isn't a surprise.
@@ -2252,7 +3639,7 @@ if [ "$target" = "pi" ]; then
2252
3639
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
2253
3640
  info "Run pi with PI_CODING_AGENT_DIR=$pi_dir pi so it picks up this config."
2254
3641
  fi
2255
- print_uninstall_hint
3642
+ [ "$mode" = "update" ] || print_uninstall_hint
2256
3643
  exit 0
2257
3644
  fi
2258
3645
 
@@ -2277,9 +3664,9 @@ cat > "$statusline_file" << 'STATUSLINE_EOF'
2277
3664
  # Renders:
2278
3665
  # WEAVE ROUTER — claude-sonnet-4-5 ← claude-opus-4-7 · saved $1.23 · 12.4k in / 3.1k out / 45.2k cached
2279
3666
  #
2280
- # Pricing source of truth: router/eval/pricing.py. Keep these maps in lockstep
2281
- # when prices change. Cache multipliers (1.25× / 0.1×) follow Anthropic's
2282
- # published cache pricing and are stable across the Claude family.
3667
+ # Pricing source of truth: internal/router/catalog. Input/output prices and
3668
+ # cache-read multipliers are generated by cmd/genprices. Cache creation remains
3669
+ # at 1.25× input pending TTL-aware pricing.
2283
3670
 
2284
3671
  set -euo pipefail
2285
3672
 
@@ -2420,6 +3807,23 @@ weave_command_tracked_by_git() {
2420
3807
  git -C "$(dirname "$1")" ls-files --error-unmatch -- "$1" >/dev/null 2>&1
2421
3808
  }
2422
3809
 
3810
+ # weave_installed_command_names lists the wrappers this install may refresh.
3811
+ # The statusline ships standalone (no registry.sh beside it), so the installed
3812
+ # set — itself written from the registry — is the only source of truth here.
3813
+ #
3814
+ # Files written before ownership markers existed carry none, so matching on the
3815
+ # marker alone would freeze every pre-marker install out of refreshes forever.
3816
+ # List every wrapper instead and let the baseline comparison below decide: a
3817
+ # file is replaced only when its bytes still match the last canonical copy, so
3818
+ # a user-authored command is never touched whether or not it carries a marker.
3819
+ weave_installed_command_names() {
3820
+ local dir="$1" file
3821
+ for file in "$dir"/*.md; do
3822
+ [ -f "$file" ] || continue
3823
+ printf '%s\n' "$(basename "$file" .md)"
3824
+ done
3825
+ }
3826
+
2423
3827
  # weave_render_command prints $1 with the installer's {{SCOPE}} placeholder
2424
3828
  # replaced by $2, matching how install_slash_commands writes the same file.
2425
3829
  # Trailing newlines are stripped on both sides of every comparison below.
@@ -2489,8 +3893,7 @@ weave_sync_commands() {
2489
3893
  # Detach stdin (CC pipes JSON to us) so curl can't consume it, and silence
2490
3894
  # everything so no output leaks into the statusline.
2491
3895
  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
3896
+ while IFS= read -r name; do
2494
3897
  installed="$cmd_dir/$name.md"
2495
3898
  # Only ever refresh a wrapper that is already installed: a missing one
2496
3899
  # was uninstalled or deliberately deleted, and resurrecting it would be
@@ -2519,10 +3922,10 @@ weave_sync_commands() {
2519
3922
  if [ -f "$prev" ]; then
2520
3923
  new_body="$(weave_render_command "$raw" "$scope_args")"
2521
3924
  prev_body="$(weave_render_command "$prev" "$scope_args")"
2522
- installed_body="$(cat "$installed" 2>/dev/null)" || installed_body=""
3925
+ installed_body="$(cat "$installed" 2>/dev/null | sed '/^<!-- weave-router managed command: .* -->$/d')" || installed_body=""
2523
3926
  if [ "$prev_body" = "$installed_body" ] && [ "$new_body" != "$installed_body" ]; then
2524
3927
  tmp="$installed.tmp.$$"
2525
- if printf '%s\n' "$new_body" >"$tmp" 2>/dev/null; then
3928
+ if printf '%s\n<!-- weave-router managed command: %s -->' "$new_body" "$name" >"$tmp" 2>/dev/null; then
2526
3929
  mv "$tmp" "$installed" 2>/dev/null || rm -f "$tmp"
2527
3930
  else
2528
3931
  rm -f "$tmp"
@@ -2530,13 +3933,152 @@ weave_sync_commands() {
2530
3933
  fi
2531
3934
  fi
2532
3935
  mv "$raw" "$prev" 2>/dev/null || rm -f "$raw"
2533
- done
3936
+ done <<EOF
3937
+ $(weave_installed_command_names "$cmd_dir")
3938
+ EOF
2534
3939
  ) >/dev/null 2>&1 &
2535
3940
  disown 2>/dev/null || true
2536
3941
  return 0
2537
3942
  }
2538
3943
  weave_sync_commands 2>/dev/null || true
2539
3944
 
3945
+ # ---------- org "hide terminal surfaces" gate ----------
3946
+ #
3947
+ # When the org has hidden the router's terminal surfaces, the statusline
3948
+ # renders nothing: this prints no output and exits 0, leaving the slot blank.
3949
+ # The setting comes from GET /v1/display-settings with the install's own
3950
+ # router key, but the foreground path NEVER touches the network: it decides
3951
+ # solely from a per-install cache file (TTL WEAVE_DISPLAY_SETTINGS_TTL_SECONDS,
3952
+ # default 1h), and a missing or stale cache fails open — the statusline
3953
+ # renders normally. When the cache is missing or stale a detached background
3954
+ # refresh re-fetches the setting and rewrites the cache atomically, so the
3955
+ # next turn picks up the fresh value; a refresh failure simply leaves the
3956
+ # cache stale, which keeps failing open rather than pinning the gate closed.
3957
+ #
3958
+ # Claude Code runs the statusline every turn, so several invocations can see a
3959
+ # stale cache at once. Letting each fetch independently is not safe: the
3960
+ # responses can land out of order, and the loser's mv would replace a newer
3961
+ # setting with an older one AND stamp it fresh, pinning the gate on a stale
3962
+ # value for a full TTL. Refreshes are therefore serialized per cache key on a
3963
+ # mkdir mutex (atomic everywhere; flock is absent on macOS), held across both
3964
+ # the fetch and the write. A refresh that finds the mutex held exits rather
3965
+ # than queueing — the in-flight one is at least as fresh as anything it would
3966
+ # fetch, so waiting only to overwrite it is the bug. The one path that is not
3967
+ # strictly exclusive is reclaiming a lock whose holder died; see below.
3968
+ weave_hidden_gate() {
3969
+ command -v curl >/dev/null 2>&1 || return 1
3970
+ command -v jq >/dev/null 2>&1 || return 1
3971
+
3972
+ local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router"
3973
+ mkdir -p "$cache_dir" 2>/dev/null || return 1
3974
+ local self="${BASH_SOURCE[0]:-$0}"
3975
+ local script_slug
3976
+ script_slug="$(printf '%s' "$self" | tr -c 'A-Za-z0-9._-' '_')"
3977
+ local cache="$cache_dir/display-settings${script_slug}"
3978
+ local ttl="${WEAVE_DISPLAY_SETTINGS_TTL_SECONDS:-3600}"
3979
+
3980
+ local now mtime fresh="false"
3981
+ now="$(date +%s 2>/dev/null)" || now=0
3982
+ if [ -f "$cache" ]; then
3983
+ mtime="$(stat -c %Y "$cache" 2>/dev/null || stat -f %m "$cache" 2>/dev/null)" || mtime=0
3984
+ if [ -n "${mtime:-}" ] && [ "$mtime" -gt 0 ] && [ $(( now - mtime )) -lt "$ttl" ]; then
3985
+ fresh="true"
3986
+ fi
3987
+ fi
3988
+
3989
+ # Foreground decision: only a fresh cache hides the surfaces. Anything else
3990
+ # (no cache, stale cache, unreadable cache) renders normally so a slow or
3991
+ # unreachable router can never stall a turn or wedge the statusline blank.
3992
+ if [ "$fresh" = "true" ]; then
3993
+ [ "$(cat "$cache" 2>/dev/null)" = "1" ]
3994
+ return
3995
+ fi
3996
+
3997
+ # Background refresh for the next invocation. Resolve the router base URL
3998
+ # and key inside the subshell from the Claude Code settings the installer
3999
+ # wrote: project/--dir installs put both under <base>/.claude alongside
4000
+ # this script (key in settings.local.json), while a user-scope install
4001
+ # lives under ~/.weave and reads ~/.claude. Resolve relative to the
4002
+ # script's own location, falling back to user scope, so a project install
4003
+ # never reads (or leaks) the user-scope key. ANTHROPIC_BASE_URL and
4004
+ # WEAVE_ROUTER_BASE_URL may also be set in the environment. A file:// base
4005
+ # URL is the offline/test seam: curl reads it as the response body
4006
+ # directly, so the endpoint path is meaningless for it; real router URLs
4007
+ # (https) get /v1/display-settings appended.
4008
+ (
4009
+ exec </dev/null
4010
+
4011
+ # Take the per-cache-key mutex, or bail. mkdir is the portable atomic
4012
+ # test-and-set. A crashed holder would otherwise block refreshes forever,
4013
+ # so a lock older than the fetch timeout is treated as abandoned and
4014
+ # reclaimed. Releasing from a trap covers every exit path below.
4015
+ lock="$cache.lock"
4016
+ if ! mkdir "$lock" 2>/dev/null; then
4017
+ lock_mtime="$(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock" 2>/dev/null)" || lock_mtime=0
4018
+ lock_now="$(date +%s 2>/dev/null)" || lock_now=0
4019
+ if [ "${lock_mtime:-0}" -le 0 ] || [ $(( lock_now - lock_mtime )) -le 30 ]; then
4020
+ exit 0
4021
+ fi
4022
+ # Reclaiming an abandoned lock must not be delete-then-recreate. With
4023
+ # `rm -rf` + `mkdir`, refreshers that all see the same stale lock each
4024
+ # delete the next one's freshly created directory, so many end up holding
4025
+ # it at once (measured at 50 claimants: 4-11 concurrent holders) and their
4026
+ # writes can land out of order. Renaming is atomic, so only one racer can
4027
+ # move a given directory aside and the losers exit instead of clobbering
4028
+ # the winner (same measurement: 1-2). It is not a perfect mutex — a
4029
+ # straggler can still reclaim the new lock a winner just created, since
4030
+ # nothing distinguishes it from the stale one — but real refreshes arrive
4031
+ # one per turn rather than 50 at once, and the staleness threshold below
4032
+ # is what bounds the rest.
4033
+ dead="$lock.dead.$$"
4034
+ mv "$lock" "$dead" 2>/dev/null || exit 0
4035
+ rm -rf "$dead" 2>/dev/null
4036
+ mkdir "$lock" 2>/dev/null || exit 0
4037
+ fi
4038
+ trap 'rmdir "$lock" 2>/dev/null' EXIT
4039
+
4040
+ self_dir="$(cd "$(dirname "$self")" 2>/dev/null && pwd)"
4041
+ settings_base="$HOME"
4042
+ case "$self_dir" in
4043
+ */.claude) settings_base="${self_dir%/.claude}" ;;
4044
+ esac
4045
+ settings="$settings_base/.claude/settings.json"
4046
+ local_settings="$settings_base/.claude/settings.local.json"
4047
+ base_url="${WEAVE_ROUTER_BASE_URL:-${ANTHROPIC_BASE_URL:-}}"
4048
+ key="${WEAVE_ROUTER_KEY:-}"
4049
+ if [ -z "$key" ] && [ -f "$settings" ]; then
4050
+ 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)"
4051
+ fi
4052
+ if [ -z "$key" ] && [ -f "$local_settings" ]; then
4053
+ 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)"
4054
+ fi
4055
+ if [ -z "$base_url" ] && [ -f "$settings" ]; then
4056
+ base_url="$(jq -r '.env.ANTHROPIC_BASE_URL // empty' "$settings" 2>/dev/null)"
4057
+ fi
4058
+ [ -n "$base_url" ] && [ -n "$key" ] || exit 0
4059
+ url="${base_url%/}"
4060
+ case "$url" in
4061
+ file://*) ;;
4062
+ *) url="$url/v1/display-settings" ;;
4063
+ esac
4064
+ body="$(curl -fsS --max-time 5 -H "X-Weave-Router-Key: $key" "$url" 2>/dev/null)" || exit 0
4065
+ hidden="$(printf '%s' "$body" | jq -r '.hide_terminal_surfaces // false' 2>/dev/null)"
4066
+ tmp="$cache.tmp.$$"
4067
+ if [ "$hidden" = "true" ]; then
4068
+ printf '1' >"$tmp" 2>/dev/null && mv "$tmp" "$cache" 2>/dev/null
4069
+ else
4070
+ printf '0' >"$tmp" 2>/dev/null && mv "$tmp" "$cache" 2>/dev/null
4071
+ fi
4072
+ rm -f "$tmp" 2>/dev/null
4073
+ ) >/dev/null 2>&1 &
4074
+ disown 2>/dev/null || true
4075
+ return 1
4076
+ }
4077
+
4078
+ if weave_hidden_gate </dev/null; then
4079
+ exit 0
4080
+ fi
4081
+
2540
4082
  input="$(cat)"
2541
4083
  transcript_path="$(printf '%s' "$input" | jq -r '.transcript_path // empty')"
2542
4084
  # Prefer model.id over display_name: pricing keys + the routed model id in
@@ -2577,6 +4119,7 @@ prices='{
2577
4119
  "claude-sonnet-5": 0.003,
2578
4120
  "deepseek/deepseek-v4-flash": 0.0001134,
2579
4121
  "deepseek/deepseek-v4-pro": 0.00174,
4122
+ "deepseek/deepseek-v4-pro-0813": 0.00174,
2580
4123
  "gemini-2.0-flash": 0.0001,
2581
4124
  "gemini-2.0-flash-lite": 0.000075,
2582
4125
  "gemini-2.5-flash": 0.0003,
@@ -2589,6 +4132,7 @@ prices='{
2589
4132
  "gemini-3.5-flash": 0.0015,
2590
4133
  "gemini-3.5-flash-lite": 0.0003,
2591
4134
  "gemini-3.6-flash": 0.0015,
4135
+ "gemini-3.7-flash": 0.0015,
2592
4136
  "google/gemma-4-26b-a4b-it": 0.00015,
2593
4137
  "gpt-4.1": 0.002,
2594
4138
  "gpt-4.1-mini": 0.0004,
@@ -2608,7 +4152,9 @@ prices='{
2608
4152
  "gpt-5.5-nano": 0.00015,
2609
4153
  "gpt-5.5-pro": 0.03,
2610
4154
  "gpt-5.6-luna": 0.001,
4155
+ "gpt-5.6-luna-pro": 0.001,
2611
4156
  "gpt-5.6-sol": 0.005,
4157
+ "gpt-5.6-sol-pro": 0.005,
2612
4158
  "gpt-5.6-terra": 0.0025,
2613
4159
  "grok-4.5": 0.002,
2614
4160
  "grok-4.6": 0.002,
@@ -2631,7 +4177,9 @@ prices='{
2631
4177
  "xiaomi/mimo-v2.5-pro": 0.001,
2632
4178
  "z-ai/glm-5": 0.001,
2633
4179
  "z-ai/glm-5.1": 0.0014,
2634
- "z-ai/glm-5.2": 0.0014
4180
+ "z-ai/glm-5.2": 0.0014,
4181
+ "z-ai/glm-5.3": 0.0014,
4182
+ "z-ai/glm-5.3-flash": 0.00015
2635
4183
  },
2636
4184
  "output": {
2637
4185
  "claude-fable-5": 0.05,
@@ -2648,6 +4196,7 @@ prices='{
2648
4196
  "claude-sonnet-5": 0.015,
2649
4197
  "deepseek/deepseek-v4-flash": 0.0002791,
2650
4198
  "deepseek/deepseek-v4-pro": 0.00348,
4199
+ "deepseek/deepseek-v4-pro-0813": 0.00348,
2651
4200
  "gemini-2.0-flash": 0.0004,
2652
4201
  "gemini-2.0-flash-lite": 0.0003,
2653
4202
  "gemini-2.5-flash": 0.0012,
@@ -2660,6 +4209,7 @@ prices='{
2660
4209
  "gemini-3.5-flash": 0.009,
2661
4210
  "gemini-3.5-flash-lite": 0.0025,
2662
4211
  "gemini-3.6-flash": 0.0075,
4212
+ "gemini-3.7-flash": 0.0075,
2663
4213
  "google/gemma-4-26b-a4b-it": 0.0006,
2664
4214
  "gpt-4.1": 0.008,
2665
4215
  "gpt-4.1-mini": 0.0016,
@@ -2679,7 +4229,9 @@ prices='{
2679
4229
  "gpt-5.5-nano": 0.0006,
2680
4230
  "gpt-5.5-pro": 0.18,
2681
4231
  "gpt-5.6-luna": 0.006,
4232
+ "gpt-5.6-luna-pro": 0.006,
2682
4233
  "gpt-5.6-sol": 0.03,
4234
+ "gpt-5.6-sol-pro": 0.03,
2683
4235
  "gpt-5.6-terra": 0.015,
2684
4236
  "grok-4.5": 0.006,
2685
4237
  "grok-4.6": 0.006,
@@ -2702,7 +4254,86 @@ prices='{
2702
4254
  "xiaomi/mimo-v2.5-pro": 0.003,
2703
4255
  "z-ai/glm-5": 0.0032,
2704
4256
  "z-ai/glm-5.1": 0.0044,
2705
- "z-ai/glm-5.2": 0.0044
4257
+ "z-ai/glm-5.2": 0.0044,
4258
+ "z-ai/glm-5.3": 0.0044,
4259
+ "z-ai/glm-5.3-flash": 0.0005
4260
+ },
4261
+ "cache_read": {
4262
+ "claude-fable-5": 0.1,
4263
+ "claude-haiku-4-5": 0.1,
4264
+ "claude-opus-4-0": 0.1,
4265
+ "claude-opus-4-1": 0.1,
4266
+ "claude-opus-4-5": 0.1,
4267
+ "claude-opus-4-6": 0.1,
4268
+ "claude-opus-4-7": 0.1,
4269
+ "claude-opus-4-8": 0.1,
4270
+ "claude-opus-5": 0.1,
4271
+ "claude-sonnet-4-5": 0.1,
4272
+ "claude-sonnet-4-6": 0.1,
4273
+ "claude-sonnet-5": 0.1,
4274
+ "deepseek/deepseek-v4-flash": 0.2,
4275
+ "deepseek/deepseek-v4-pro": 0.11494252873563218,
4276
+ "deepseek/deepseek-v4-pro-0813": 0.11494252873563218,
4277
+ "gemini-2.0-flash": 0.25,
4278
+ "gemini-2.0-flash-lite": 0.25,
4279
+ "gemini-2.5-flash": 0.1,
4280
+ "gemini-2.5-flash-lite": 0.1,
4281
+ "gemini-2.5-pro": 0.1,
4282
+ "gemini-3-flash-preview": 0.1,
4283
+ "gemini-3-pro-preview": 0.1,
4284
+ "gemini-3.1-flash-lite-preview": 0.1,
4285
+ "gemini-3.1-pro-preview": 0.1,
4286
+ "gemini-3.5-flash": 0.1,
4287
+ "gemini-3.5-flash-lite": 0.1,
4288
+ "gemini-3.6-flash": 0.1,
4289
+ "gemini-3.7-flash": 0.1,
4290
+ "google/gemma-4-26b-a4b-it": 0.1,
4291
+ "gpt-4.1": 0.25,
4292
+ "gpt-4.1-mini": 0.25,
4293
+ "gpt-4.1-nano": 0.25,
4294
+ "gpt-4o": 0.5,
4295
+ "gpt-4o-mini": 0.5,
4296
+ "gpt-5": 0.1,
4297
+ "gpt-5-chat": 0.1,
4298
+ "gpt-5-mini": 0.1,
4299
+ "gpt-5-nano": 0.1,
4300
+ "gpt-5.4": 0.1,
4301
+ "gpt-5.4-mini": 0.1,
4302
+ "gpt-5.4-nano": 0.1,
4303
+ "gpt-5.4-pro": 1,
4304
+ "gpt-5.5": 0.1,
4305
+ "gpt-5.5-mini": 0.1,
4306
+ "gpt-5.5-nano": 0.1,
4307
+ "gpt-5.5-pro": 1,
4308
+ "gpt-5.6-luna": 0.1,
4309
+ "gpt-5.6-luna-pro": 0.1,
4310
+ "gpt-5.6-sol": 0.1,
4311
+ "gpt-5.6-sol-pro": 0.1,
4312
+ "gpt-5.6-terra": 0.1,
4313
+ "grok-4.5": 0.25,
4314
+ "grok-4.6": 0.25,
4315
+ "minimax/minimax-m2.7": 0.2,
4316
+ "minimax/minimax-m3": 0.2,
4317
+ "mistralai/mistral-small-2603": 0.1,
4318
+ "moonshotai/kimi-k2.5": 0.5,
4319
+ "moonshotai/kimi-k2.6": 0.1684,
4320
+ "moonshotai/kimi-k2.7": 0.2,
4321
+ "moonshotai/kimi-k3": 0.1,
4322
+ "qwen/qwen3-235b-a22b-2507": 0.5,
4323
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.1684,
4324
+ "qwen/qwen3-coder": 0.1684,
4325
+ "qwen/qwen3-coder-next": 0.5,
4326
+ "qwen/qwen3-next-80b-a3b-instruct": 0.5,
4327
+ "qwen/qwen3.5-flash-02-23": 0.1,
4328
+ "qwen/qwen3.6-35b-a3b": 0.1,
4329
+ "qwen/qwen3.7-plus": 0.2,
4330
+ "qwen/qwen3.8-max": 0.125,
4331
+ "xiaomi/mimo-v2.5-pro": 0.1,
4332
+ "z-ai/glm-5": 0.2,
4333
+ "z-ai/glm-5.1": 0.18571428571428572,
4334
+ "z-ai/glm-5.2": 0.18571428571428572,
4335
+ "z-ai/glm-5.3": 0.18571428571428572,
4336
+ "z-ai/glm-5.3-flash": 0.2
2706
4337
  }
2707
4338
  }'
2708
4339
  # END_GENERATED_PRICES
@@ -2757,6 +4388,12 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2757
4388
  # * unrecognized model → "… isn't a recognized model · keeping
2758
4389
  # automatic routing" — a NO-OP: the prior
2759
4390
  # pin, if any, is left untouched
4391
+ # * listing (historical) → "… pick a model by id …" / "no models are
4392
+ # available to pin …" — also NO-OPs. The
4393
+ # router no longer emits these, but a
4394
+ # transcript written while it did still
4395
+ # carries them, and they must not read as
4396
+ # a clear or a live pin loses its [forced]
2760
4397
  # These persist on disk (the ingress stripper only scrubs them from upstream
2761
4398
  # requests). Classify each weave-router turn newest-first, skip the no-op
2762
4399
  # "rejected" acks, and let the latest real state change decide: an "applied"
@@ -2770,6 +4407,7 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2770
4407
  | ([.message.content[]? | select(.type? == "text") | .text] | join(" ") | gsub("[\n\r]"; " ")) as $t
2771
4408
  | if ($t | test("force-model applied:")) then "APPLIED " + ($t | capture("force-model applied: (?<m>[^ ]+)").m)
2772
4409
  elif ($t | test("isn.t a recognized model")) then "REJECTED"
4410
+ elif ($t | test("pick a model by id|no models are available to pin")) then "REJECTED"
2773
4411
  else "CLEARED" end' 2>/dev/null \
2774
4412
  | grep -m1 -v '^REJECTED$' || true)"
2775
4413
  if [[ "$force_state" == APPLIED\ * ]]; then
@@ -2780,12 +4418,9 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2780
4418
  # Compute a session running total: savings across every assistant turn
2781
4419
  # whose marker reports a requested ≠ routed swap, plus cumulative token
2782
4420
  # counts across every assistant turn (rerouted or not — total work the
2783
- # session has done). cache_creation is priced at 1.25× input, cache_read
2784
- # at 0.1× both ratios are stable across the Claude family and a no-op
2785
- # when the provider doesn't return those fields. Cache reads ARE included
2786
- # in the savings comparison: both costs apply the same 0.1× weight to
2787
- # cache_read_input_tokens, so the delta reflects the model-price
2788
- # difference on the cached portion as well.
4421
+ # session has done). cache_creation is priced at 1.25× input; cache_read
4422
+ # uses each model's generated catalog multiplier. Both are no-ops when the
4423
+ # provider does not return those fields.
2789
4424
  #
2790
4425
  # The marker regex tolerates the optional "(<provider>)" segment and a
2791
4426
  # `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
@@ -2818,14 +4453,17 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
2818
4453
  } as $t |
2819
4454
  (if $requested == "" or $requested == $rm then 0
2820
4455
  else
2821
- ($p.input[$rm] // null) as $rin | ($p.output[$rm] // null) as $rout |
2822
- ($p.input[$requested] // null) as $sin | ($p.output[$requested] // null) as $sout |
2823
- if ($rin == null or $rout == null or $sin == null or $sout == null) then 0
4456
+ ($p.input[$rm] // null) as $rin | ($p.output[$rm] // null) as $rout |
4457
+ ($p.cache_read[$rm] // null) as $rcr |
4458
+ ($p.input[$requested] // null) as $sin | ($p.output[$requested] // null) as $sout |
4459
+ ($p.cache_read[$requested] // null) as $scr |
4460
+ if ($rin == null or $rout == null or $rcr == null or $sin == null or $sout == null or $scr == null) then 0
2824
4461
  else
2825
- (($t.in + 1.25 * $t.cwrt + 0.1 * $t.crd) / 1000) as $input_units |
2826
- ($t.out / 1000) as $output_units |
2827
- ($input_units * $rin + $output_units * $rout) as $routed_cost |
2828
- ($input_units * $sin + $output_units * $sout) as $requested_cost |
4462
+ (($t.in + 1.25 * $t.cwrt + $rcr * $t.crd) / 1000) as $routed_input_units |
4463
+ (($t.in + 1.25 * $t.cwrt + $scr * $t.crd) / 1000) as $requested_input_units |
4464
+ ($t.out / 1000) as $output_units |
4465
+ ($routed_input_units * $rin + $output_units * $rout) as $routed_cost |
4466
+ ($requested_input_units * $sin + $output_units * $sout) as $requested_cost |
2829
4467
  ($requested_cost - $routed_cost)
2830
4468
  end
2831
4469
  end) as $savings |
@@ -3034,8 +4672,8 @@ write_claude_settings() {
3034
4672
  ok "Settings written to $settings_file"
3035
4673
 
3036
4674
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
3037
- jq -n --arg header "$custom_headers" '{
3038
- env: { ANTHROPIC_CUSTOM_HEADERS: $header }
4675
+ jq -n --arg header "$custom_headers" --arg router_url "$base_url" '{
4676
+ env: { ANTHROPIC_CUSTOM_HEADERS: $header, WEAVE_ROUTER_BASE_URL: $router_url }
3039
4677
  }' >"$tmp_patch"
3040
4678
  if [ -f "$local_settings_file" ]; then
3041
4679
  # Also strip ANTHROPIC_BASE_URL: `off` in project scope points this file
@@ -3097,61 +4735,9 @@ fi
3097
4735
 
3098
4736
  # ---------- post-install verification ----------
3099
4737
 
3100
- if [ "$quiet" != "true" ]; then
3101
- if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
3102
- warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
3103
- fi
3104
- fi
3105
-
3106
- if [ -n "$api_key" ]; then
3107
- # Pass the router key via stdin (`@-`) instead of a -H argument so the key
3108
- # never appears in the process arg list (visible via `ps` / /proc to other
3109
- # local users on shared machines). We feed stdin via a small wrapper so the
3110
- # spinner's exec form sees a single command argv.
3111
- validate_key() {
3112
- printf '%s: %s\n' "$router_key_header" "$api_key" \
3113
- | curl -fsS --max-time 5 --header @- "$base_url/validate"
3114
- }
3115
- if ! spin "Validating API key" validate_key; then
3116
- # A key we read back off disk can have been revoked or rotated since it was
3117
- # installed, and reusing it silently would leave a broken install behind
3118
- # exactly where the old behavior (always prompt) would have fixed it. Ask
3119
- # once, then rewrite the settings with whatever the user pastes. Anywhere a
3120
- # prompt isn't available — non-interactive, update, --quiet, no tty, or a
3121
- # key that didn't come from disk — keep the historical warn-and-continue.
3122
- if [ "$api_key_source" = "disk" ] && [ "$non_interactive" != "true" ] \
3123
- && [ "$quiet" != "true" ] && [ -r /dev/tty ]; then
3124
- warn "The router key already installed was rejected (revoked or rotated)."
3125
- prompt_for_key
3126
- write_claude_settings "$api_key"
3127
- if ! spin "Validating API key" validate_key; then
3128
- warn "Router rejected the API key (check it matches the dashboard at $base_url)."
3129
- fi
3130
- elif [ "$mode" = "update" ]; then
3131
- # update is meant for cron/scripting: a rejected key is a real failure,
3132
- # not a note in the log. --quiet callers opted out of the noise, not out
3133
- # of the nonzero exit — a scheduled run that reports success here would
3134
- # hide a revoked key indefinitely.
3135
- if [ "$quiet" = "true" ]; then
3136
- warn "Router rejected the installed API key. Re-run the installer to set a new one."
3137
- else
3138
- err "Router rejected the installed API key. Re-run the installer to set a new one."
3139
- fi
3140
- exit 1
3141
- else
3142
- warn "Router rejected the API key (check it matches the dashboard at $base_url)."
3143
- fi
3144
- fi
3145
- fi
4738
+ verify_install
3146
4739
 
3147
4740
  # ---------- done ----------
3148
4741
 
3149
- printf "\n"
3150
- if [ "$mode" = "update" ]; then
3151
- printf "%s✓%s %s%sWeave Router config refreshed for Claude Code.%s\n" \
3152
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
3153
- exit 0
3154
- fi
3155
- printf "%s✓%s %s%sWeave Router installed for Claude Code.%s\n" \
3156
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
3157
- print_uninstall_hint
4742
+ announce_done "Claude Code"
4743
+ [ "$mode" = "update" ] || print_uninstall_hint