@workweave/router 0.2.11 → 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.
package/install.sh CHANGED
@@ -49,10 +49,11 @@
49
49
  # npx @workweave/router --uninstall # remove a previous install (delegates to uninstall.sh)
50
50
  #
51
51
  # Re-running the installer reuses the key already on disk, so you only paste it
52
- # once. `update` is the scriptable form of that: it never prompts, refreshes the
53
- # managed config + assets in place, and errors (rather than asking) when no key
54
- # can be found:
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:
55
55
  # npx @workweave/router update --claude # refresh the Claude Code install in place
56
+ # npx @workweave/router update --codex # same for Codex / opencode / pi
56
57
  #
57
58
  # Toggle an existing install on/off without losing the router config (so
58
59
  # switching back is instant). These never prompt for a key and require an
@@ -353,7 +354,6 @@ refuse_if_symlink() {
353
354
  # --codex) can find and replace the block instead of duplicating it.
354
355
  WEAVE_CODEX_BEGIN_MARKER="# >>> weave-router managed (do not edit between markers) >>>"
355
356
  WEAVE_CODEX_END_MARKER="# <<< weave-router managed <<<"
356
- WEAVE_CODEX_SKILL_MARKER="<!-- weave-router managed disable-routing skill -->"
357
357
 
358
358
  # ---------- identity helpers ----------
359
359
  #
@@ -502,11 +502,12 @@ write_codex_config() {
502
502
  printf '%s' "${s//\"/\\\"}"
503
503
  }
504
504
 
505
- local esc_key esc_email esc_name esc_url
505
+ local esc_key esc_email esc_name esc_url esc_status
506
506
  esc_key="$(toml_escape "$block_key")"
507
507
  esc_email="$(toml_escape "$block_email")"
508
508
  esc_name="$(toml_escape "$block_name")"
509
509
  esc_url="$(toml_escape "$block_url")"
510
+ esc_status="$(toml_escape "$codex_status_file")"
510
511
 
511
512
  # Plant whichever identity values we have alongside the router key so the
512
513
  # router can attribute Codex traffic to a person on shared keys. Build the
@@ -528,12 +529,45 @@ write_codex_config() {
528
529
  # reaches them. Every endpoint, hosted or self-hosted, uses its own default.
529
530
  local headers_line="http_headers = { ${headers_parts} }"
530
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
531
564
  local block
532
565
  block="$(cat <<TOML
533
566
  ${WEAVE_CODEX_BEGIN_MARKER}
534
567
  # Managed by the Weave Router installer. Re-running the installer rewrites
535
568
  # this block; \`./uninstall.sh --codex\` removes it. To opt out without
536
569
  # uninstalling, change the model_provider value below.
570
+ ${hook_feature_line}
537
571
  model_provider = "weave"
538
572
 
539
573
  [model_providers.weave]
@@ -542,6 +576,7 @@ base_url = "${esc_url}/v1"
542
576
  wire_api = "responses"
543
577
  requires_openai_auth = true
544
578
  ${headers_line}
579
+ ${hook_block}
545
580
  ${WEAVE_CODEX_END_MARKER}
546
581
  TOML
547
582
  )"
@@ -596,6 +631,25 @@ TOML
596
631
  else
597
632
  printf "%s\n" "$block" >"$config_file"
598
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
599
653
  # 0600: the file holds a router key. Even at user scope, mode 644 would
600
654
  # leak the key to any local user on a shared box.
601
655
  chmod 600 "$config_file"
@@ -1149,7 +1203,7 @@ if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
1149
1203
  non_interactive="true"
1150
1204
  if [ "$target_explicit" != "true" ]; then
1151
1205
  if [ "$mode" = "models" ]; then
1152
- err "'models' requires an explicit client: --claude."
1206
+ err "'models' requires an explicit client: --claude or --codex."
1153
1207
  else
1154
1208
  err "'$mode' requires an explicit client: --claude, --codex, or --opencode."
1155
1209
  fi
@@ -1157,12 +1211,13 @@ if [ "$mode" != "install" ] && [ "$mode" != "update" ]; then
1157
1211
  fi
1158
1212
  fi
1159
1213
 
1160
- # Only Claude Code's settings are read back for a key today (read_installed_key),
1161
- # so `models` can't authenticate as any other client. Fail fast here rather than
1162
- # falling through to the toggle dispatch, which would flip config nobody asked
1163
- # to change.
1164
- if [ "$mode" = "models" ] && [ "$target" != "claude" ]; then
1165
- err "'models' currently supports --claude only (it reads the router key from Claude Code's settings)."
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)."
1166
1221
  exit 2
1167
1222
  fi
1168
1223
 
@@ -1185,16 +1240,14 @@ if [ "$mode" = "update" ]; then
1185
1240
  err "--rotate-key needs a prompt, which 'update' never issues. Re-run the installer without 'update'."
1186
1241
  exit 2
1187
1242
  fi
1188
- if [ "$target" != "claude" ]; then
1189
- err "'update' currently supports --claude only. Re-run the installer for $target (it reuses your installed key)."
1190
- exit 2
1191
- fi
1192
1243
  fi
1193
1244
 
1194
1245
  # Toggle verbs (off/on/status) aren't implemented for pi — its config is a
1195
1246
  # structural models.json/settings.json merge, reversed by the uninstaller
1196
- # rather than a single env/key line we can park and restore.
1197
- 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
1198
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."
1199
1252
  exit 2
1200
1253
  fi
@@ -1318,7 +1371,13 @@ fi
1318
1371
  # Claude Code's settings.json and opencode's opencode.json patching both use
1319
1372
  # jq to deep-merge / structurally rewrite JSON. Toggling those clients reads
1320
1373
  # and rewrites the same JSON, so jq is required there too.
1321
- 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
1322
1381
  require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
1323
1382
  fi
1324
1383
  # curl is used by the install/update paths' health/validate probes and by every
@@ -1375,12 +1434,13 @@ WEAVE_REGISTRY_DATA=$(cat <<'WEAVE_REGISTRY_EOF'
1375
1434
  force-model|fm|prompt|yes|yes|yes|yes|manual|command,skill
1376
1435
  unforce-model|ufm|prompt|yes|yes|yes|yes|manual|command,skill
1377
1436
  router-feedback|rf|prompt|yes|yes|yes|no|manual|command,skill
1378
- router-off||local-toggle|yes|no|no|no|manual|command
1379
- router-on||local-toggle|yes|no|no|no|manual|command
1380
- router-status||local-toggle|yes|no|no|no|manual|command
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
1381
1440
  router-session||prompt|yes|no|no|no|manual|command
1382
- router-models|models|local-toggle|yes|no|no|no|manual|command
1441
+ router-models|models|local-toggle|yes|yes|no|no|manual|command,skill
1383
1442
  disable-routing||local-toggle|no|yes|no|no|manual|skill
1443
+ beta||prompt|yes|no|no|yes|manual|command
1384
1444
  WEAVE_REGISTRY_EOF
1385
1445
  )
1386
1446
 
@@ -1429,6 +1489,7 @@ weave_registry_skill_names() {
1429
1489
  cursor) [ "$cursor" = yes ] || continue ;;
1430
1490
  esac
1431
1491
  printf '%s\n' "$canonical"
1492
+ [ -n "$aliases" ] && printf '%s\n' "$aliases" | tr ',' '\n'
1432
1493
  done <<EOF
1433
1494
  $(weave_registry_rows)
1434
1495
  EOF
@@ -1448,6 +1509,7 @@ weave_registry_skill_assets() {
1448
1509
  cursor) [ "$cursor" = yes ] || continue ;;
1449
1510
  esac
1450
1511
  printf '%s\n' "$canonical"
1512
+ [ -n "$aliases" ] && printf '%s\n' "$aliases" | tr ',' '\n'
1451
1513
  done <<EOF
1452
1514
  $(weave_registry_rows)
1453
1515
  EOF
@@ -1544,10 +1606,18 @@ elif [ "$target" = "codex" ]; then
1544
1606
  # teammate — .codex/config.toml goes in .gitignore in project scope.
1545
1607
  codex_dir="$settings_base/.codex"
1546
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"
1547
1615
 
1548
1616
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1549
1617
  refuse_if_symlink "$codex_dir"
1550
1618
  refuse_if_symlink "$codex_config_file"
1619
+ refuse_if_symlink "$codex_status_file"
1620
+ refuse_if_symlink "$codex_status_disabled_marker"
1551
1621
  fi
1552
1622
 
1553
1623
  mkdir -p "$codex_dir"
@@ -1664,14 +1734,93 @@ claude_key_present() {
1664
1734
  [ -n "$(read_claude_key "$1")" ]
1665
1735
  }
1666
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
+
1667
1803
  # read_installed_key prints the router key this install already has on disk, or
1668
- # nothing. Only Claude Code is supported today; the other targets still prompt.
1669
- # Defined up here rather than with the rest of token handling because `models`
1670
- # dispatches (and needs a key) before that section runs.
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.
1671
1808
  #
1672
- # models_key_file_order echoes the precedence this uses, so a caller that needs
1673
- # to know *which* file the key came from can walk the same list (a command
1674
- # substitution around this function would discard any global it set).
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
+
1675
1824
  models_key_file_order() {
1676
1825
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1677
1826
  printf '%s\n%s\n%s\n' "$local_settings_file" "$settings_file" "$settings_dir/.weave-parked.json"
@@ -1680,8 +1829,7 @@ models_key_file_order() {
1680
1829
  fi
1681
1830
  }
1682
1831
 
1683
- read_installed_key() {
1684
- [ "$target" = "claude" ] || return 0
1832
+ read_claude_installed_key() {
1685
1833
  local key="" candidate
1686
1834
  # Mirror where the install path writes the key: project scope (no --dir) puts
1687
1835
  # it in the gitignored settings.local.json, everything else inlines it into
@@ -1699,6 +1847,15 @@ read_installed_key() {
1699
1847
  printf '%s' "$key"
1700
1848
  }
1701
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
+
1702
1859
  # resolve_installed_base_url prints the router endpoint this Claude Code install
1703
1860
  # already points at, or nothing when none of its files carry a router-shaped
1704
1861
  # one. The parked sidecar comes first: `off` moves the router URL there and
@@ -1724,6 +1881,54 @@ resolve_installed_base_url() {
1724
1881
  printf ''
1725
1882
  }
1726
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
+
1727
1932
  # models_endpoint_is_trusted returns 0 when it is safe to send the router key
1728
1933
  # resolved from $2 to the endpoint resolved from $1.
1729
1934
  #
@@ -1962,6 +2167,9 @@ toggle_codex() {
1962
2167
  {print}
1963
2168
  ' "$f" >"$tmp" && mv "$tmp" "$f"
1964
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
1965
2173
  ok "Codex is ${C_BOLD}off${C_RESET} (default provider). Takes effect on your next 'codex' run."
1966
2174
  ;;
1967
2175
  on)
@@ -1977,6 +2185,9 @@ toggle_codex() {
1977
2185
  {print}
1978
2186
  ' "$f" >"$tmp" && mv "$tmp" "$f"
1979
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
1980
2191
  ok "Codex is ${C_BOLD}on${C_RESET} (routing through the Weave Router). Takes effect on your next 'codex' run."
1981
2192
  ;;
1982
2193
  esac
@@ -2134,7 +2345,7 @@ models_fail() {
2134
2345
  if [ -n "$detail" ]; then
2135
2346
  err "$detail"
2136
2347
  else
2137
- err "The router rejected this installation's key. Re-run 'npx @workweave/router --claude --rotate-key' to install a current one."
2348
+ err "The router rejected this installation's key. Re-run 'npx @workweave/router --$target --rotate-key' to install a current one."
2138
2349
  fi
2139
2350
  ;;
2140
2351
  404)
@@ -2246,8 +2457,8 @@ models_list() {
2246
2457
  fi
2247
2458
  models_render_list "$models_http_body"
2248
2459
  models_print_preferred
2249
- printf "\n%sEnable a model:%s npx @workweave/router models enable <id> --claude\n" "$C_DIM" "$C_RESET"
2250
- printf "%sDisable a model:%s npx @workweave/router models disable <id> --claude\n" "$C_DIM" "$C_RESET"
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"
2251
2462
  }
2252
2463
 
2253
2464
  models_providers_list() {
@@ -2331,14 +2542,17 @@ models_prefer() {
2331
2542
  }
2332
2543
 
2333
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}"
2334
2548
  err "$1"
2335
2549
  printf '%s\n' \
2336
- " npx @workweave/router models --claude # list models" \
2337
- " npx @workweave/router models enable <id> [<id>…] --claude" \
2338
- " npx @workweave/router models disable <id> [<id>…] --claude" \
2339
- " npx @workweave/router models providers --claude # list providers" \
2340
- " npx @workweave/router models providers disable <name> --claude" \
2341
- " npx @workweave/router models prefer <id> [<id>…] --claude # ranking ('clear' to drop)" >&2
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
2342
2556
  exit 2
2343
2557
  }
2344
2558
 
@@ -2394,23 +2608,30 @@ if [ "$mode" = "models" ]; then
2394
2608
  # never the hosted defaults: a self-hosted user pointing at their own router
2395
2609
  # would otherwise silently edit the hosted one's installation.
2396
2610
  if [ "$base_url_explicit" != "true" ]; then
2397
- models_base="$(resolve_installed_base_url)"
2611
+ models_base="$(resolve_installed_endpoint)"
2398
2612
  if [ -z "$models_base" ]; then
2399
- err "No Weave Router install found for Claude Code in this scope. Run 'npx @workweave/router --claude' first, or pass --base-url."
2613
+ err "No Weave Router install found for $target in this scope. Run 'npx @workweave/router --$target' first, or pass --base-url."
2400
2614
  exit 1
2401
2615
  fi
2402
2616
  base_url="$models_base"
2403
- # resolve_installed_base_url ran in a command substitution, so any global it
2617
+ # resolve_installed_endpoint ran in a command substitution, so any global it
2404
2618
  # set died with that subshell. Recover the source by walking the same
2405
- # precedence to find which file holds the endpoint we just adopted.
2406
- while IFS= read -r models_src_candidate; do
2407
- [ -n "$models_src_candidate" ] || continue
2408
- models_src_url="$(json_get "$models_src_candidate" '.env.ANTHROPIC_BASE_URL')"
2409
- if [ "${models_src_url%/}" = "$models_base" ]; then
2410
- models_base_source="$models_src_candidate"
2411
- break
2412
- fi
2413
- done <<<"$(models_base_file_order)"
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
2414
2635
  fi
2415
2636
  # WEAVE_ROUTER_KEY is the user's own choice, so it pairs with any endpoint.
2416
2637
  if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
@@ -2421,16 +2642,20 @@ if [ "$mode" = "models" ]; then
2421
2642
  # Same subshell caveat as the endpoint above: recover which file the key
2422
2643
  # came from by re-reading them in read_installed_key's own precedence.
2423
2644
  models_key_source=""
2424
- while IFS= read -r models_src_candidate; do
2425
- [ -n "$models_src_candidate" ] || continue
2426
- if [ -n "$(read_claude_key "$models_src_candidate")" ]; then
2427
- models_key_source="$models_src_candidate"
2428
- break
2429
- fi
2430
- done <<<"$(models_key_file_order)"
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
2431
2656
  fi
2432
2657
  if [ -z "$api_key" ]; then
2433
- err "No router key found for Claude Code in this scope. Re-run 'npx @workweave/router --claude', or export WEAVE_ROUTER_KEY."
2658
+ err "No router key found for $target in this scope. Re-run 'npx @workweave/router --$target', or export WEAVE_ROUTER_KEY."
2434
2659
  exit 1
2435
2660
  fi
2436
2661
  # Never send a key to an endpoint the checkout supplied. See
@@ -2472,8 +2697,8 @@ fi
2472
2697
  # `off` parks the router URL and points Claude Code at Anthropic, so the parked
2473
2698
  # sidecar is the authority while toggled off — reading the live file there would
2474
2699
  # pin the install to api.anthropic.com.
2475
- if [ "$mode" = "update" ] && [ "$base_url_explicit" != "true" ] && [ "$target" = "claude" ]; then
2476
- installed_base="$(resolve_installed_base_url)"
2700
+ if [ "$mode" = "update" ] && [ "$base_url_explicit" != "true" ]; then
2701
+ installed_base="$(resolve_installed_endpoint)"
2477
2702
  if [ -n "$installed_base" ]; then
2478
2703
  base_url="${installed_base%/}"
2479
2704
  fi
@@ -2745,63 +2970,29 @@ EOF
2745
2970
  rmdir "$dst_dir" 2>/dev/null || true
2746
2971
  }
2747
2972
 
2748
- # Codex skills are the supported local extension surface for an action that
2749
- # must edit Codex's configuration. A literal third-party slash command cannot
2750
- # do this because Codex reserves `/…` for built-ins. The skill is invoked as
2751
- # `$disable-routing`, asks Codex to run the existing safe toggle, and leaves
2752
- # the managed router block in place for a later re-enable.
2753
- install_codex_disable_routing_skill() {
2754
- local skill_src=""
2755
- local candidate dst_dir dst_file scope_args body
2756
- for candidate in \
2757
- "$script_dir/codex-skills/disable-routing/SKILL.md" \
2758
- "$script_dir/../codex-skills/disable-routing/SKILL.md"
2759
- do
2760
- if [ -f "$candidate" ]; then
2761
- skill_src="$candidate"
2762
- break
2763
- fi
2764
- done
2765
- [ -n "$skill_src" ] || { warn "Codex disable-routing skill template is missing; router configuration is still installed."; return 0; }
2766
- grep -Fq "$WEAVE_CODEX_SKILL_MARKER" "$skill_src" \
2767
- || { warn "Codex disable-routing skill template has no ownership marker; leaving skills unchanged."; return 0; }
2768
-
2769
- dst_dir="$codex_dir/skills/disable-routing"
2770
- dst_file="$dst_dir/SKILL.md"
2771
- if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
2772
- refuse_if_symlink "$codex_dir/skills"
2773
- refuse_if_symlink "$dst_dir"
2774
- refuse_if_symlink "$dst_file"
2775
- elif [ -L "$codex_dir/skills" ] || [ -L "$dst_dir" ] || [ -L "$dst_file" ]; then
2776
- warn "Codex disable-routing skill path contains a symlink; leaving it untouched."
2777
- return 0
2778
- fi
2779
- if [ -e "$dst_file" ] && { [ ! -f "$dst_file" ] || ! grep -Fq "$WEAVE_CODEX_SKILL_MARKER" "$dst_file"; }; then
2780
- warn "A user-owned Codex disable-routing skill already exists at $dst_file; leaving it untouched."
2781
- return 0
2782
- fi
2783
- mkdir -p "$dst_dir"
2784
-
2785
- scope_args=""
2786
- if [ -n "$install_dir" ]; then
2787
- scope_args=" --dir $(printf '%q' "$install_dir")"
2788
- elif [ "$scope" = "project" ]; then
2789
- scope_args=" --scope project"
2790
- fi
2791
- body="$(<"$skill_src")"
2792
- body="${body//\{\{SCOPE\}\}/$scope_args}"
2793
- printf '%s\n' "$body" >"$dst_file"
2794
- ok "Codex skill installed: \$disable-routing"
2795
- }
2796
2973
 
2797
- # Install prompt directives as Codex-native skills. The skill itself emits a
2798
- # leading-space normal prompt, which is the only portable way to reach the
2799
- # router directive parser without claiming Codex's reserved slash namespace.
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).
2800
2983
  install_codex_prompt_skills() {
2801
- local canonical skill_src dst_dir dst_file scope_args body
2984
+ local canonical candidate skill_src dst_dir dst_file emit_src emit_dst scope_args body
2802
2985
  while IFS= read -r canonical; do
2803
- skill_src="$script_dir/codex-skills/$canonical/SKILL.md"
2804
- [ -f "$skill_src" ] || continue
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
2992
+ skill_src="$candidate"
2993
+ break
2994
+ done
2995
+ [ -n "$skill_src" ] || continue
2805
2996
  grep -Fq "<!-- weave-router managed $canonical skill -->" "$skill_src" || continue
2806
2997
  dst_dir="$codex_dir/skills/$canonical"
2807
2998
  dst_file="$dst_dir/SKILL.md"
@@ -2824,18 +3015,480 @@ install_codex_prompt_skills() {
2824
3015
  body="$(<"$skill_src")"
2825
3016
  body="${body//\{\{SCOPE\}\}/$scope_args}"
2826
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
2827
3034
  ok "Codex skill installed: \$$canonical"
2828
3035
  done <<EOF
2829
- $(weave_registry_skill_names codex)
3036
+ $(weave_registry_skill_assets codex)
2830
3037
  EOF
2831
3038
  }
2832
3039
 
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
3077
+ fi
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
3097
+ return 0
3098
+ fi
3099
+
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
3111
+ fi
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"
3129
+ }
3130
+
3131
+ # ---------- codex install path (dispatch + exit before the Claude-only writes) ----------
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
+ }
2833
3483
 
2834
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
2835
3489
  write_codex_config "$codex_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
2836
3490
  ok "Codex config written to $codex_config_file"
2837
3491
  remove_obsolete_codex_prompt_wrappers "$codex_dir/prompts"
2838
- install_codex_disable_routing_skill
2839
3492
  install_codex_prompt_skills
2840
3493
  info "Codex router directives: begin the message with one space, e.g. ' /force-model gpt-5.6-terra'."
2841
3494
 
@@ -2847,44 +3500,26 @@ if [ "$target" = "codex" ]; then
2847
3500
  gitignore="$git_root/.gitignore"
2848
3501
  refuse_if_symlink "$gitignore"
2849
3502
  for entry in \
2850
- ".codex/config.toml"
3503
+ ".codex/config.toml" \
3504
+ ".codex/weave-status.sh" \
3505
+ ".codex/.weave-router-disabled"
2851
3506
  do
2852
3507
  if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
2853
3508
  printf '%s\n' "$entry" >>"$gitignore"
2854
3509
  fi
2855
3510
  done
2856
- ok "Updated $gitignore (ignored .codex/config.toml)"
2857
- fi
2858
-
2859
- # Post-install verification: same probes the Claude path runs so a working
2860
- # install gives the same green checks regardless of target.
2861
- if [ "$quiet" != "true" ]; then
2862
- if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
2863
- warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
2864
- fi
3511
+ ok "Updated $gitignore (ignored Codex router config and status helper)"
2865
3512
  fi
2866
3513
 
2867
- if [ -n "$api_key" ]; then
2868
- # Pass the router key via stdin (`@-`) instead of -H so it never lands in
2869
- # the process arg list. Mirrors the Claude-path validate logic.
2870
- validate_codex_key() {
2871
- printf '%s: %s\n' "$router_key_header" "$api_key" \
2872
- | curl -fsS --max-time 5 --header @- "$base_url/validate"
2873
- }
2874
- if ! spin "Validating API key" validate_codex_key; then
2875
- warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
2876
- fi
2877
- fi
3514
+ verify_install
2878
3515
 
2879
- printf "\n"
2880
- printf "%s✓%s %s%sWeave Router installed for Codex.%s\n" \
2881
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
3516
+ announce_done "Codex"
2882
3517
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
2883
3518
  # Codex auto-discovers ~/.codex; for non-user installs the caller has to
2884
3519
  # point CODEX_HOME at the directory we wrote so Codex finds our config.
2885
3520
  info "Run Codex with CODEX_HOME=$codex_dir codex so it picks up this config."
2886
3521
  fi
2887
- print_uninstall_hint
3522
+ [ "$mode" = "update" ] || print_uninstall_hint
2888
3523
  exit 0
2889
3524
  fi
2890
3525
 
@@ -2932,26 +3567,9 @@ if [ "$target" = "opencode" ]; then
2932
3567
  info "opencode restart required for commands to take effect."
2933
3568
  fi
2934
3569
 
2935
- # Post-install verification: same probes the Claude/Codex paths run.
2936
- if [ "$quiet" != "true" ]; then
2937
- if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
2938
- warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
2939
- fi
2940
- fi
2941
-
2942
- if [ -n "$api_key" ]; then
2943
- validate_opencode_key() {
2944
- printf '%s: %s\n' "$router_key_header" "$api_key" \
2945
- | curl -fsS --max-time 5 --header @- "$base_url/validate"
2946
- }
2947
- if ! spin "Validating API key" validate_opencode_key; then
2948
- warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
2949
- fi
2950
- fi
3570
+ verify_install
2951
3571
 
2952
- printf "\n"
2953
- printf "%s✓%s %s%sWeave Router installed for opencode.%s\n" \
2954
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
3572
+ announce_done "opencode"
2955
3573
  # Surface the optional subscription-routing path only when this run actually
2956
3574
  # registered the weave-claude login provider (which is written together with
2957
3575
  # the plugin). Gate on its presence in the written config (authoritative)
@@ -2966,7 +3584,7 @@ if [ "$target" = "opencode" ]; then
2966
3584
  # has to point opencode at the file explicitly.
2967
3585
  info "Run opencode with OPENCODE_CONFIG=$opencode_config_file opencode."
2968
3586
  fi
2969
- print_uninstall_hint
3587
+ [ "$mode" = "update" ] || print_uninstall_hint
2970
3588
  exit 0
2971
3589
  fi
2972
3590
 
@@ -3007,30 +3625,13 @@ if [ "$target" = "pi" ]; then
3007
3625
  ok "Updated $gitignore (ignored repo-local .pi router config)"
3008
3626
  fi
3009
3627
 
3010
- # Post-install verification: same probes the Claude/Codex/opencode paths run.
3011
- if [ "$quiet" != "true" ]; then
3012
- if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
3013
- warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
3014
- fi
3015
- fi
3016
-
3017
- if [ -n "$api_key" ]; then
3018
- validate_pi_key() {
3019
- printf '%s: %s\n' "$router_key_header" "$api_key" \
3020
- | curl -fsS --max-time 5 --header @- "$base_url/validate"
3021
- }
3022
- if ! spin "Validating API key" validate_pi_key; then
3023
- warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
3024
- fi
3025
- fi
3628
+ verify_install
3026
3629
 
3027
3630
  if [ -n "$lsp_langs" ]; then
3028
3631
  install_lsp_servers "$lsp_langs"
3029
3632
  fi
3030
3633
 
3031
- printf "\n"
3032
- printf "%s✓%s %s%sWeave Router installed for pi.%s\n" \
3033
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
3634
+ announce_done "pi"
3034
3635
  # Billing note: pi normally draws on a Claude subscription (OAuth); routing
3035
3636
  # through the router switches to per-token billing on the router deployment
3036
3637
  # key (or BYOK). Surface it at install so the change isn't a surprise.
@@ -3038,7 +3639,7 @@ if [ "$target" = "pi" ]; then
3038
3639
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
3039
3640
  info "Run pi with PI_CODING_AGENT_DIR=$pi_dir pi so it picks up this config."
3040
3641
  fi
3041
- print_uninstall_hint
3642
+ [ "$mode" = "update" ] || print_uninstall_hint
3042
3643
  exit 0
3043
3644
  fi
3044
3645
 
@@ -3063,9 +3664,9 @@ cat > "$statusline_file" << 'STATUSLINE_EOF'
3063
3664
  # Renders:
3064
3665
  # WEAVE ROUTER — claude-sonnet-4-5 ← claude-opus-4-7 · saved $1.23 · 12.4k in / 3.1k out / 45.2k cached
3065
3666
  #
3066
- # Pricing source of truth: router/eval/pricing.py. Keep these maps in lockstep
3067
- # when prices change. Cache multipliers (1.25× / 0.1×) follow Anthropic's
3068
- # 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.
3069
3670
 
3070
3671
  set -euo pipefail
3071
3672
 
@@ -3577,6 +4178,7 @@ prices='{
3577
4178
  "z-ai/glm-5": 0.001,
3578
4179
  "z-ai/glm-5.1": 0.0014,
3579
4180
  "z-ai/glm-5.2": 0.0014,
4181
+ "z-ai/glm-5.3": 0.0014,
3580
4182
  "z-ai/glm-5.3-flash": 0.00015
3581
4183
  },
3582
4184
  "output": {
@@ -3653,7 +4255,85 @@ prices='{
3653
4255
  "z-ai/glm-5": 0.0032,
3654
4256
  "z-ai/glm-5.1": 0.0044,
3655
4257
  "z-ai/glm-5.2": 0.0044,
4258
+ "z-ai/glm-5.3": 0.0044,
3656
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
3657
4337
  }
3658
4338
  }'
3659
4339
  # END_GENERATED_PRICES
@@ -3738,12 +4418,9 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
3738
4418
  # Compute a session running total: savings across every assistant turn
3739
4419
  # whose marker reports a requested ≠ routed swap, plus cumulative token
3740
4420
  # counts across every assistant turn (rerouted or not — total work the
3741
- # session has done). cache_creation is priced at 1.25× input, cache_read
3742
- # at 0.1× both ratios are stable across the Claude family and a no-op
3743
- # when the provider doesn't return those fields. Cache reads ARE included
3744
- # in the savings comparison: both costs apply the same 0.1× weight to
3745
- # cache_read_input_tokens, so the delta reflects the model-price
3746
- # 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.
3747
4424
  #
3748
4425
  # The marker regex tolerates the optional "(<provider>)" segment and a
3749
4426
  # `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
@@ -3776,14 +4453,17 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
3776
4453
  } as $t |
3777
4454
  (if $requested == "" or $requested == $rm then 0
3778
4455
  else
3779
- ($p.input[$rm] // null) as $rin | ($p.output[$rm] // null) as $rout |
3780
- ($p.input[$requested] // null) as $sin | ($p.output[$requested] // null) as $sout |
3781
- 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
3782
4461
  else
3783
- (($t.in + 1.25 * $t.cwrt + 0.1 * $t.crd) / 1000) as $input_units |
3784
- ($t.out / 1000) as $output_units |
3785
- ($input_units * $rin + $output_units * $rout) as $routed_cost |
3786
- ($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 |
3787
4467
  ($requested_cost - $routed_cost)
3788
4468
  end
3789
4469
  end) as $savings |
@@ -4055,61 +4735,9 @@ fi
4055
4735
 
4056
4736
  # ---------- post-install verification ----------
4057
4737
 
4058
- if [ "$quiet" != "true" ]; then
4059
- if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
4060
- warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
4061
- fi
4062
- fi
4063
-
4064
- if [ -n "$api_key" ]; then
4065
- # Pass the router key via stdin (`@-`) instead of a -H argument so the key
4066
- # never appears in the process arg list (visible via `ps` / /proc to other
4067
- # local users on shared machines). We feed stdin via a small wrapper so the
4068
- # spinner's exec form sees a single command argv.
4069
- validate_key() {
4070
- printf '%s: %s\n' "$router_key_header" "$api_key" \
4071
- | curl -fsS --max-time 5 --header @- "$base_url/validate"
4072
- }
4073
- if ! spin "Validating API key" validate_key; then
4074
- # A key we read back off disk can have been revoked or rotated since it was
4075
- # installed, and reusing it silently would leave a broken install behind
4076
- # exactly where the old behavior (always prompt) would have fixed it. Ask
4077
- # once, then rewrite the settings with whatever the user pastes. Anywhere a
4078
- # prompt isn't available — non-interactive, update, --quiet, no tty, or a
4079
- # key that didn't come from disk — keep the historical warn-and-continue.
4080
- if [ "$api_key_source" = "disk" ] && [ "$non_interactive" != "true" ] \
4081
- && [ "$quiet" != "true" ] && [ -r /dev/tty ]; then
4082
- warn "The router key already installed was rejected (revoked or rotated)."
4083
- prompt_for_key
4084
- write_claude_settings "$api_key"
4085
- if ! spin "Validating API key" validate_key; then
4086
- warn "Router rejected the API key (check it matches the dashboard at $base_url)."
4087
- fi
4088
- elif [ "$mode" = "update" ]; then
4089
- # update is meant for cron/scripting: a rejected key is a real failure,
4090
- # not a note in the log. --quiet callers opted out of the noise, not out
4091
- # of the nonzero exit — a scheduled run that reports success here would
4092
- # hide a revoked key indefinitely.
4093
- if [ "$quiet" = "true" ]; then
4094
- warn "Router rejected the installed API key. Re-run the installer to set a new one."
4095
- else
4096
- err "Router rejected the installed API key. Re-run the installer to set a new one."
4097
- fi
4098
- exit 1
4099
- else
4100
- warn "Router rejected the API key (check it matches the dashboard at $base_url)."
4101
- fi
4102
- fi
4103
- fi
4738
+ verify_install
4104
4739
 
4105
4740
  # ---------- done ----------
4106
4741
 
4107
- printf "\n"
4108
- if [ "$mode" = "update" ]; then
4109
- printf "%s✓%s %s%sWeave Router config refreshed for Claude Code.%s\n" \
4110
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
4111
- exit 0
4112
- fi
4113
- printf "%s✓%s %s%sWeave Router installed for Claude Code.%s\n" \
4114
- "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
4115
- print_uninstall_hint
4742
+ announce_done "Claude Code"
4743
+ [ "$mode" = "update" ] || print_uninstall_hint