@workweave/router 0.1.7 → 0.2.0

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
@@ -1,35 +1,42 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # Weave Router installer for Claude Code, Codex, and opencode.
3
+ # Weave Router installer for Claude Code, Codex, opencode, and pi.
4
4
  #
5
- # Configures Claude Code (default), the OpenAI Codex CLI (`--codex`), or
6
- # opencode (`--opencode`) to permanently route through the Weave Router.
7
- # For Claude Code this writes the router base URL, router auth header,
8
- # and a status line into Claude Code's settings.json. For Codex it writes
9
- # a `model_providers.weave` entry plus `model_provider = "weave"` into
5
+ # Configures Claude Code (default), the OpenAI Codex CLI (`--codex`),
6
+ # opencode (`--opencode`), or pi (`--pi`) to permanently route through the
7
+ # Weave Router. For Claude Code this writes the router base URL, router auth
8
+ # header, and a status line into Claude Code's settings.json. For Codex it
9
+ # writes a `model_providers.weave` entry plus `model_provider = "weave"` into
10
10
  # ~/.codex/config.toml (managed block delimited by markers). For opencode
11
11
  # it merges a `provider.weave` block (anthropic-compatible) into
12
12
  # opencode.json — since the file is JSON, install/uninstall are structural
13
- # (jq) rather than marker-delimited.
13
+ # (jq) rather than marker-delimited. For pi it merges a `weave` provider into
14
+ # ~/.pi/agent/models.json, sets it as the default in settings.json, and adds
15
+ # the @workweave/router extension (which also adds a parallel subagent
16
+ # `dispatch` tool) — all structural (jq) merges.
14
17
  #
15
18
  # Two scopes (apply to all targets):
16
19
  # - user (default): ~/.claude/settings.json + ~/.weave/cc-statusline.sh
17
20
  # ~/.codex/config.toml (with --codex)
18
21
  # ~/.config/opencode/opencode.json (with --opencode)
22
+ # ~/.pi/agent/{models,settings}.json (with --pi)
19
23
  # - project: <repo>/.claude/settings.json + <repo>/.claude/cc-statusline.sh
20
24
  # <repo>/.codex/config.toml (with --codex)
21
25
  # <repo>/opencode.json (with --opencode)
26
+ # <repo>/.pi/ (run: PI_CODING_AGENT_DIR=<repo>/.pi pi) (with --pi)
22
27
  #
23
28
  # Or pass --dir to install into any directory:
24
29
  # - dir: <dir>/.claude/settings.json + <dir>/.claude/cc-statusline.sh
25
30
  # <dir>/.codex/config.toml (with --codex)
26
31
  # <dir>/opencode.json (with --opencode)
32
+ # <dir>/.pi/ (run: PI_CODING_AGENT_DIR=<dir>/.pi pi) (with --pi)
27
33
  #
28
34
  # Usage:
29
35
  # npx @workweave/router # interactive picker (Claude Code, Codex, opencode)
30
36
  # npx @workweave/router --claude # skip the picker, target Claude Code
31
37
  # npx @workweave/router --codex # skip the picker, target the OpenAI Codex CLI
32
38
  # npx @workweave/router --opencode # skip the picker, target opencode
39
+ # npx @workweave/router --pi # skip the picker, target pi
33
40
  # npx @workweave/router --scope project # commit-with-team install
34
41
  # npx @workweave/router --dir /tmp/my-sandbox # isolated throwaway install
35
42
  # npx @workweave/router --local # local router on localhost:8080
@@ -124,6 +131,7 @@ uninstall_cmd() {
124
131
  case "$target" in
125
132
  codex) cmd="$cmd --codex" ;;
126
133
  opencode) cmd="$cmd --opencode" ;;
134
+ pi) cmd="$cmd --pi" ;;
127
135
  esac
128
136
  [ "$scope" = "project" ] && cmd="$cmd --scope project"
129
137
  [ -n "$install_dir" ] && cmd="$cmd --dir $(printf '%q' "$install_dir")"
@@ -149,6 +157,7 @@ print_banner() {
149
157
  case "$target" in
150
158
  codex) target_label="Codex installer" ;;
151
159
  opencode) target_label="opencode installer" ;;
160
+ pi) target_label="pi installer" ;;
152
161
  *) target_label="Claude Code installer" ;;
153
162
  esac
154
163
  printf '\n'
@@ -567,6 +576,12 @@ write_opencode_config() {
567
576
  # a startup error before the router ever sees a request. The router itself
568
577
  # ignores the value (auth runs off X-Weave-Router-Key); apiKey here is just
569
578
  # a placeholder that satisfies the SDK's "is auth configured" check.
579
+ # baseURL KEEPS its /v1 here — do NOT align it with the pi block, which is
580
+ # root. opencode's @ai-sdk/anthropic (Vercel) appends only /messages to
581
+ # baseURL (its default is api.anthropic.com/v1), so /v1 yields the correct
582
+ # /v1/messages. Dropping it would hit /messages and 404. (pi uses the official
583
+ # @anthropic-ai/sdk, which appends /v1/messages to a root baseURL — opposite
584
+ # convention.) Verified by install/pi-router/test/opencode_smoke.sh.
570
585
  local block
571
586
  block="$(jq -n \
572
587
  --arg url "$block_url/v1" \
@@ -611,6 +626,117 @@ write_opencode_config() {
611
626
  chmod 600 "$config_file"
612
627
  }
613
628
 
629
+ # write_pi_models_config merges a managed `weave` provider into pi's
630
+ # models.json (anthropic-compatible — the router speaks Anthropic Messages
631
+ # natively). The header set carries identity plus the main-loop routing knobs
632
+ # (quality bias); the @workweave/router extension re-registers the provider
633
+ # per process to flip those knobs for subagents/compaction. apiKey is the
634
+ # router key as well as a header — pi treats apiKey as required to consider auth
635
+ # configured, but the router authenticates off X-Weave-Router-Key
636
+ # (authHeader:false keeps Authorization free for BYOK). Re-running rewrites
637
+ # `.providers.weave` in place; uninstall strips it. chmod 600 — holds the key.
638
+ #
639
+ # Usage: write_pi_models_config <models_file> <base_url> <api_key> [user_email] [user_name]
640
+ write_pi_models_config() {
641
+ local config_file="$1"
642
+ local block_url="$2"
643
+ local block_key="$3"
644
+ local block_email="${4:-}"
645
+ local block_name="${5:-}"
646
+
647
+ # Identity + the main-loop (quality) routing knobs. Built piecewise so an
648
+ # empty email/name vanishes from the JSON entirely.
649
+ local headers_json
650
+ headers_json="$(jq -n \
651
+ --arg key "$block_key" \
652
+ --arg email "$block_email" \
653
+ --arg name "$block_name" '
654
+ {
655
+ "X-Weave-Router-Key": $key,
656
+ "X-App": "pi",
657
+ "x-weave-routing-marker": "off",
658
+ "x-weave-routing-alpha": "0.8",
659
+ "x-weave-routing-speed-weight": "0.05",
660
+ "x-weave-routing-output-cost-ratio": "0.5",
661
+ "x-weave-routing-expected-output-tokens": "3000"
662
+ }
663
+ | (if $email != "" then . + {"X-Weave-User-Email": $email} else . end)
664
+ | (if $name != "" then . + {"X-Weave-User-Name": $name } else . end)
665
+ ')"
666
+
667
+ # Headline models surfaced in pi's /model picker. The router re-routes every
668
+ # request regardless, so this list is UX; keep it Anthropic-shaped and in
669
+ # sync with @workweave/router's WEAVE_MODELS constant.
670
+ #
671
+ # baseUrl is the router ROOT (no /v1): pi's anthropic-messages provider uses
672
+ # @anthropic-ai/sdk, which appends /v1/messages itself. Unlike the codex block
673
+ # above (OpenAI-style, base ends in /v1), a /v1 suffix here would produce
674
+ # /v1/v1/messages and 404.
675
+ local block
676
+ block="$(jq -n \
677
+ --arg url "$block_url" \
678
+ --arg key "$block_key" \
679
+ --argjson headers "$headers_json" '
680
+ {
681
+ baseUrl: $url,
682
+ api: "anthropic-messages",
683
+ apiKey: $key,
684
+ authHeader: false,
685
+ headers: $headers,
686
+ models: [
687
+ { id: "claude-opus-4-8", name: "Claude Opus 4.8 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
688
+ { id: "claude-opus-4-7", name: "Claude Opus 4.7 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
689
+ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 64000 },
690
+ { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (via Weave Router)", reasoning: true, input: ["text","image"], contextWindow: 200000, maxTokens: 32000 }
691
+ ]
692
+ }
693
+ ')"
694
+
695
+ # Overwrite provider.weave only; leave any other providers/models the user
696
+ # added untouched.
697
+ local merged
698
+ if [ -f "$config_file" ]; then
699
+ merged="$(jq --argjson block "$block" '.providers = ((.providers // {}) | .weave = $block)' "$config_file")"
700
+ else
701
+ merged="$(jq -n --argjson block "$block" '{ providers: { weave: $block } }')"
702
+ fi
703
+ printf '%s\n' "$merged" >"$config_file"
704
+ # 0600: the headers + apiKey hold the router key.
705
+ chmod 600 "$config_file"
706
+ }
707
+
708
+ # write_pi_settings_config makes the `weave` provider pi's default and loads the
709
+ # @workweave/router extension. defaultProvider/defaultModel are set only when
710
+ # unset (don't clobber a user's pick); the npm package source is appended to
711
+ # `packages` idempotently — pi auto-installs missing packages on startup, so the
712
+ # source entry is enough — and the legacy `npm:@workweave/pi-router` id (from
713
+ # before the extension was folded into @workweave/router) is dropped so a config
714
+ # from the old layout can't keep a dangling/duplicate entry. No secret lives
715
+ # here, so no chmod 600.
716
+ #
717
+ # Usage: write_pi_settings_config <settings_file>
718
+ write_pi_settings_config() {
719
+ local settings_file="$1"
720
+ local pkg="npm:@workweave/router"
721
+ local merged
722
+ if [ -f "$settings_file" ]; then
723
+ merged="$(jq --arg pkg "$pkg" '
724
+ (.packages //= [])
725
+ | (.packages -= ["npm:@workweave/pi-router"])
726
+ | (if (.packages | index($pkg)) then . else .packages += [$pkg] end)
727
+ | (if (.defaultProvider // "") == "" then .defaultProvider = "weave" else . end)
728
+ | (if (.defaultModel // "") == "" then .defaultModel = "claude-sonnet-4-6" else . end)
729
+ ' "$settings_file")"
730
+ else
731
+ merged="$(jq -n --arg pkg "$pkg" '{
732
+ defaultProvider: "weave",
733
+ defaultModel: "claude-sonnet-4-6",
734
+ packages: [$pkg]
735
+ }')"
736
+ fi
737
+ printf '%s\n' "$merged" >"$settings_file"
738
+ }
739
+
614
740
  # resolve_user_name mirrors resolve_user_email but for display name. Priority:
615
741
  # WEAVE_USER_NAME env override → git config user.name → empty. We don't
616
742
  # prompt for name independently: if email prompting yielded nothing, name
@@ -716,6 +842,9 @@ while [ $# -gt 0 ]; do
716
842
  --opencode)
717
843
  target="opencode"; target_explicit="true"; shift
718
844
  ;;
845
+ --pi)
846
+ target="pi"; target_explicit="true"; shift
847
+ ;;
719
848
  --claude)
720
849
  # No-op selector for symmetry with --codex / --opencode. Useful in
721
850
  # pipelines that want to skip the interactive picker without depending
@@ -747,6 +876,14 @@ if [ "$mode" != "install" ]; then
747
876
  fi
748
877
  fi
749
878
 
879
+ # Toggle verbs (off/on/status) aren't implemented for pi — its config is a
880
+ # structural models.json/settings.json merge, reversed by the uninstaller
881
+ # rather than a single env/key line we can park and restore.
882
+ if [ "$mode" != "install" ] && [ "$target" = "pi" ]; then
883
+ 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."
884
+ exit 2
885
+ fi
886
+
750
887
  if [ -z "$base_url" ]; then
751
888
  base_url="$DEFAULT_BASE_URL"
752
889
  fi
@@ -766,12 +903,14 @@ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r
766
903
  printf " %s1)%s Claude Code %s— patches ~/.claude/settings.json (or <repo>/.claude/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
767
904
  printf " %s2)%s Codex %s— patches ~/.codex/config.toml (or <repo>/.codex/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
768
905
  printf " %s3)%s opencode %s— patches ~/.config/opencode/opencode.json (or <repo>/opencode.json)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
769
- printf "Choose %s[1/2/3]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
906
+ printf " %s4)%s pi %s— patches ~/.pi/agent/models.json + settings.json (or <repo>/.pi/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
907
+ printf "Choose %s[1/2/3/4]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
770
908
  read -r target_choice </dev/tty || target_choice=""
771
909
  case "${target_choice:-1}" in
772
910
  1|""|claude|c|C) target="claude" ;;
773
911
  2|codex|x|X) target="codex" ;;
774
912
  3|opencode|o|O) target="opencode" ;;
913
+ 4|pi|p|P) target="pi" ;;
775
914
  *) err "invalid choice: $target_choice"; exit 2 ;;
776
915
  esac
777
916
  fi
@@ -809,6 +948,11 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
809
948
  scope_project_path="<repo>/opencode.json"
810
949
  scope_cli_label="opencode"
811
950
  ;;
951
+ pi)
952
+ scope_user_path="~/.pi/agent/"
953
+ scope_project_path="<repo>/.pi/"
954
+ scope_cli_label="pi"
955
+ ;;
812
956
  *)
813
957
  scope_user_path="~/.claude/"
814
958
  scope_project_path="<repo>/.claude/"
@@ -859,7 +1003,7 @@ fi
859
1003
  # Claude Code's settings.json and opencode's opencode.json patching both use
860
1004
  # jq to deep-merge / structurally rewrite JSON. Toggling those clients reads
861
1005
  # and rewrites the same JSON, so jq is required there too.
862
- if [ "$target" = "claude" ] || [ "$target" = "opencode" ]; then
1006
+ if [ "$target" = "claude" ] || [ "$target" = "opencode" ] || [ "$target" = "pi" ]; then
863
1007
  require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
864
1008
  fi
865
1009
  # curl is only used by the install path's health/validate probes; toggles never
@@ -885,6 +1029,12 @@ case "$target" in
885
1029
  warn "Continuing — opencode.json will be written and will take effect once opencode is installed."
886
1030
  fi
887
1031
  ;;
1032
+ pi)
1033
+ if ! command -v pi >/dev/null 2>&1; then
1034
+ warn "'pi' not found on PATH. Install with 'npm install -g @mariozechner/pi-coding-agent', then re-run this script."
1035
+ warn "Continuing — models.json/settings.json will be written and take effect once pi is installed."
1036
+ fi
1037
+ ;;
888
1038
  esac
889
1039
 
890
1040
  script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
@@ -970,6 +1120,31 @@ elif [ "$target" = "codex" ]; then
970
1120
  fi
971
1121
 
972
1122
  mkdir -p "$codex_dir"
1123
+ elif [ "$target" = "pi" ]; then
1124
+ # pi reads ~/.pi/agent/ by default, so a plain `pi` picks up a user-scope
1125
+ # install with no env var. models.json is global-only in pi (there is no
1126
+ # project-level models file), so for project/--dir scope we point
1127
+ # PI_CODING_AGENT_DIR at a repo-local .pi that holds the whole config
1128
+ # (models.json + settings.json + key) — the same shape as Codex's CODEX_HOME.
1129
+ # The router key is embedded, so .pi goes in .gitignore for project scope.
1130
+ case "$scope" in
1131
+ user) pi_dir="$settings_base/.pi/agent" ;;
1132
+ project) pi_dir="$settings_base/.pi" ;;
1133
+ esac
1134
+ # --dir is a self-contained sandbox: flat .pi, launched via PI_CODING_AGENT_DIR.
1135
+ [ -n "$install_dir" ] && pi_dir="$install_dir/.pi"
1136
+ pi_models_file="$pi_dir/models.json"
1137
+ pi_settings_file="$pi_dir/settings.json"
1138
+ pi_key_file="$pi_dir/.weave_router_key"
1139
+
1140
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1141
+ refuse_if_symlink "$pi_dir"
1142
+ refuse_if_symlink "$pi_models_file"
1143
+ refuse_if_symlink "$pi_settings_file"
1144
+ refuse_if_symlink "$pi_key_file"
1145
+ fi
1146
+
1147
+ mkdir -p "$pi_dir"
973
1148
  else
974
1149
  # opencode discovers config in this order: $XDG_CONFIG_HOME/opencode/opencode.json
975
1150
  # (or ~/.config/opencode/opencode.json) for user scope, and opencode.json /
@@ -1035,6 +1210,19 @@ json_get() {
1035
1210
  jq -r "${2} // empty" "$1" 2>/dev/null || true
1036
1211
  }
1037
1212
 
1213
+ # claude_key_present returns 0 when the given settings file's
1214
+ # env.ANTHROPIC_CUSTOM_HEADERS carries the router key header. "On" is only valid
1215
+ # when this is true: in project scope the committed settings.json holds the
1216
+ # router URL but the key header lives only in the per-teammate settings.local.json
1217
+ # (or the parked sidecar), so a router URL alone doesn't mean requests can
1218
+ # authenticate.
1219
+ claude_key_present() {
1220
+ case "$(json_get "$1" '.env.ANTHROPIC_CUSTOM_HEADERS')" in
1221
+ *X-Weave-Router-Key*) return 0 ;;
1222
+ *) return 1 ;;
1223
+ esac
1224
+ }
1225
+
1038
1226
  # gitignore_add appends an entry to the repo .gitignore in project scope so a
1039
1227
  # parked sidecar (which may carry the router key header) never gets committed.
1040
1228
  # No-op for user scope and --dir, matching how install handles its own ignores.
@@ -1056,6 +1244,15 @@ toggle_claude() {
1056
1244
  else
1057
1245
  active="$settings_file"
1058
1246
  fi
1247
+ # Symlink containment for the parked sidecar: project/--dir paths come from a
1248
+ # possibly-hostile repo, and `off` writes $parked via shell redirection (which
1249
+ # follows symlinks). A repo could pre-place it as a symlink to clobber an
1250
+ # arbitrary file or siphon the router-key-bearing parked data. The config
1251
+ # files themselves are already guarded during path resolution; this covers the
1252
+ # sidecar. User scope ($HOME) is trusted, matching the installer.
1253
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1254
+ refuse_if_symlink "$parked"
1255
+ fi
1059
1256
  local parked_present="false"
1060
1257
  if [ -f "$parked" ]; then parked_present="true"; fi
1061
1258
  committed_base="$(json_get "$settings_file" '.env.ANTHROPIC_BASE_URL')"
@@ -1071,12 +1268,23 @@ toggle_claude() {
1071
1268
  if [ -n "$local_base" ] && ! router_shaped_url "$local_base"; then
1072
1269
  ok "Claude Code (project): ${C_BOLD}off${C_RESET} — routing directly to Anthropic. Run '$on_hint' to re-enable."
1073
1270
  elif router_shaped_url "$committed_base"; then
1074
- ok "Claude Code (project): ${C_BOLD}on${C_RESET} routing through $committed_base."
1271
+ # Router URL is committed, but it only authenticates if this teammate's
1272
+ # settings.local.json carries the key header. A fresh clone (shared
1273
+ # settings.json, no personal local file) has the URL but no key.
1274
+ if claude_key_present "$local_settings_file"; then
1275
+ ok "Claude Code (project): ${C_BOLD}on${C_RESET} — routing through $committed_base."
1276
+ else
1277
+ warn "Claude Code (project): router URL is set but your personal router key is missing (no settings.local.json) — requests won't authenticate. Run the installer to add your key."
1278
+ fi
1075
1279
  else
1076
1280
  info "Claude Code (project): not configured for the router. Run the installer first."
1077
1281
  fi
1078
1282
  elif router_shaped_url "$committed_base"; then
1079
- ok "Claude Code: ${C_BOLD}on${C_RESET} — routing through $committed_base."
1283
+ if claude_key_present "$settings_file"; then
1284
+ ok "Claude Code: ${C_BOLD}on${C_RESET} — routing through $committed_base."
1285
+ else
1286
+ warn "Claude Code: router URL is set but the router key header is missing — requests won't authenticate. Run the installer to restore it."
1287
+ fi
1080
1288
  else
1081
1289
  info "Claude Code: not configured for the router. Run the installer first."
1082
1290
  fi
@@ -1125,22 +1333,53 @@ toggle_claude() {
1125
1333
  if [ "$proj" = "true" ]; then
1126
1334
  local_base="$(json_get "$local_settings_file" '.env.ANTHROPIC_BASE_URL')"
1127
1335
  if [ -n "$local_base" ] && ! router_shaped_url "$local_base"; then
1128
- merged="$(jq '(.env // {}) |= del(.ANTHROPIC_BASE_URL)
1129
- | (if (.env // {} | length) == 0 then del(.env) else . end)' "$local_settings_file")"
1130
- printf '%s\n' "$merged" >"$local_settings_file"
1131
- chmod 600 "$local_settings_file"
1132
- ok "Claude Code is now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
1336
+ # We're off, but the parked sidecar is gone. The router key header
1337
+ # lives only in the local file / sidecar in project scope never in
1338
+ # committed settings.json — so we can only re-enable cleanly if the
1339
+ # header survived in the local file. If it didn't, clearing the
1340
+ # override would point Claude Code at the router with no auth
1341
+ # (401s); leave the working direct setup in place and tell the user
1342
+ # to reinstall instead of faking success.
1343
+ if claude_key_present "$local_settings_file"; then
1344
+ merged="$(jq '(.env // {}) |= del(.ANTHROPIC_BASE_URL)
1345
+ | (if (.env // {} | length) == 0 then del(.env) else . end)' "$local_settings_file")"
1346
+ printf '%s\n' "$merged" >"$local_settings_file"
1347
+ chmod 600 "$local_settings_file"
1348
+ ok "Claude Code is now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
1349
+ else
1350
+ warn "Claude Code is off and the parked router key is missing (its sidecar was deleted). Re-run the installer to restore the router key — leaving the current direct-to-Anthropic setup in place so requests don't fail auth."
1351
+ fi
1133
1352
  return 0
1134
1353
  fi
1135
1354
  fi
1136
- if router_shaped_url "$committed_base"; then
1355
+ # No direct override (or user scope). "On" requires both the router URL
1356
+ # and the key header — a committed router URL with no local key (e.g. a
1357
+ # fresh clone) can't authenticate, so don't claim it's already on.
1358
+ if ! router_shaped_url "$committed_base"; then
1359
+ warn "No parked router config found. Run the installer to set up Claude Code."
1360
+ elif claude_key_present "$active"; then
1137
1361
  ok "Claude Code is already on — nothing to do."
1138
1362
  else
1139
- warn "No parked router config found. Run the installer to set up Claude Code."
1363
+ # $active is settings.local.json in project scope, settings.json for
1364
+ # user/--dir — name the right file so the hint isn't misleading.
1365
+ warn "Router URL is set but the router key is missing — run the installer to add your key (written to $(basename "$active"))."
1140
1366
  fi
1141
1367
  return 0
1142
1368
  fi
1143
- parked_env="$(jq '.env' "$parked")"
1369
+ # Sidecar present: restore it — but only if the result actually carries
1370
+ # the router key. An off that ran with an empty/absent settings.local.json
1371
+ # parks {"env":{}}; blindly restoring that would drop the direct override
1372
+ # and leave the committed router URL unauthenticated while printing
1373
+ # success. Refuse that and tell the user to reinstall.
1374
+ parked_env="$(jq -c '.env // {}' "$parked")"
1375
+ parked_has_key="false"
1376
+ if printf '%s' "$parked_env" | jq -e '(.ANTHROPIC_CUSTOM_HEADERS // "") | test("X-Weave-Router-Key")' >/dev/null 2>&1; then
1377
+ parked_has_key="true"
1378
+ fi
1379
+ if [ "$parked_has_key" != "true" ] && ! claude_key_present "$active"; then
1380
+ warn "Can't re-enable: the parked config has no router key (it was created without one). Re-run the installer to set up your router key — leaving the current direct-to-Anthropic setup in place."
1381
+ return 0
1382
+ fi
1144
1383
  merged="$(jq --argjson p "$parked_env" '.env = (((.env // {}) | del(.ANTHROPIC_BASE_URL)) + $p)' "$active")"
1145
1384
  printf '%s\n' "$merged" >"$active"
1146
1385
  [ "$proj" = "true" ] && chmod 600 "$active"
@@ -1206,6 +1445,12 @@ toggle_codex() {
1206
1445
  toggle_opencode() {
1207
1446
  local f="$opencode_config_file" parked="$opencode_dir/.weave-parked.json"
1208
1447
  local model="" has_weave="false" parked_present="false" on="false" restore_model merged
1448
+ # Symlink containment for the parked sidecar — `off` writes it via shell
1449
+ # redirection; a hostile project repo could pre-place it as a symlink. The
1450
+ # opencode.json itself is already guarded during path resolution.
1451
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1452
+ refuse_if_symlink "$parked"
1453
+ fi
1209
1454
  if [ -f "$parked" ]; then parked_present="true"; fi
1210
1455
  if [ -f "$f" ]; then
1211
1456
  model="$(jq -r '.model // empty' "$f" 2>/dev/null || true)"
@@ -1243,6 +1488,16 @@ toggle_opencode() {
1243
1488
  restore_model="$(jq -r '.model // "weave/claude-sonnet-4-6"' "$parked")"
1244
1489
  elif [ "$has_weave" != "true" ]; then
1245
1490
  warn "opencode isn't configured for the router. Run the installer first."; return 0
1491
+ else
1492
+ # No parked model (sidecar deleted by hand). Derive the default from the
1493
+ # installed provider.weave.models block rather than a hardcoded literal
1494
+ # that silently diverges when the installer's default changes — prefer a
1495
+ # sonnet entry, else the first model the installer registered.
1496
+ restore_model="$(jq -r '
1497
+ (.provider.weave.models // {} | keys) as $k
1498
+ | (([$k[] | select(test("sonnet"))] | first) // $k[0] // "claude-sonnet-4-6")
1499
+ | "weave/" + .
1500
+ ' "$f" 2>/dev/null || echo "weave/claude-sonnet-4-6")"
1246
1501
  fi
1247
1502
  merged="$(jq --arg m "$restore_model" '.model = $m' "$f")"
1248
1503
  printf '%s\n' "$merged" >"$f"
@@ -1501,6 +1756,74 @@ if [ "$target" = "opencode" ]; then
1501
1756
  exit 0
1502
1757
  fi
1503
1758
 
1759
+ # ---------- pi install path (dispatch + exit before the Claude-only writes) ----------
1760
+
1761
+ if [ "$target" = "pi" ]; then
1762
+ write_pi_models_config "$pi_models_file" "$base_url" "$api_key" "$user_email" "$user_name"
1763
+ ok "pi models config written to $pi_models_file"
1764
+ write_pi_settings_config "$pi_settings_file"
1765
+ ok "pi settings written to $pi_settings_file (provider weave + @workweave/router)"
1766
+
1767
+ if [ -n "$api_key" ]; then
1768
+ printf '%s\n' "$api_key" >"$pi_key_file"
1769
+ chmod 600 "$pi_key_file"
1770
+ ok "Router key written to $pi_key_file"
1771
+ fi
1772
+
1773
+ # Project scope: the repo-local .pi carries the router key, so keep it out of
1774
+ # git. Same reasoning as the Codex/opencode paths — base URL is shared, the
1775
+ # key is per-person.
1776
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
1777
+ # Write the .gitignore in the directory that CONTAINS .pi (the chosen project
1778
+ # dir), not the git root: gitignore entries with a slash are anchored to the
1779
+ # .gitignore's own location, so a root-level ".pi/models.json" would NOT match
1780
+ # a nested <subdir>/.pi/ — leaking the router key. dirname "$pi_dir" == the
1781
+ # project dir (== git root when they're the same).
1782
+ gitignore="$(dirname "$pi_dir")/.gitignore"
1783
+ refuse_if_symlink "$gitignore"
1784
+ for entry in \
1785
+ ".pi/models.json" \
1786
+ ".pi/settings.json" \
1787
+ ".pi/.weave_router_key"
1788
+ do
1789
+ if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
1790
+ printf '%s\n' "$entry" >>"$gitignore"
1791
+ fi
1792
+ done
1793
+ ok "Updated $gitignore (ignored repo-local .pi router config)"
1794
+ fi
1795
+
1796
+ # Post-install verification: same probes the Claude/Codex/opencode paths run.
1797
+ if [ "$quiet" != "true" ]; then
1798
+ if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
1799
+ warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
1800
+ fi
1801
+ fi
1802
+
1803
+ if [ -n "$api_key" ]; then
1804
+ validate_pi_key() {
1805
+ printf '%s: %s\n' "$router_key_header" "$api_key" \
1806
+ | curl -fsS --max-time 5 --header @- "$base_url/validate"
1807
+ }
1808
+ if ! spin "Validating API key" validate_pi_key; then
1809
+ warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
1810
+ fi
1811
+ fi
1812
+
1813
+ printf "\n"
1814
+ printf "%s✓%s %s%sWeave Router installed for pi.%s\n" \
1815
+ "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
1816
+ # Billing note: pi normally draws on a Claude subscription (OAuth); routing
1817
+ # through the router switches to per-token billing on the router deployment
1818
+ # key (or BYOK). Surface it at install so the change isn't a surprise.
1819
+ info "pi now bills per token on the Weave Router key, not your Claude subscription."
1820
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1821
+ info "Run pi with PI_CODING_AGENT_DIR=$pi_dir pi so it picks up this config."
1822
+ fi
1823
+ print_uninstall_hint
1824
+ exit 0
1825
+ fi
1826
+
1504
1827
  # ---------- write the statusline script ----------
1505
1828
 
1506
1829
  cat > "$statusline_file" << 'STATUSLINE_EOF'
package/package.json CHANGED
@@ -1,16 +1,22 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.1.7",
4
- "description": "One-command installer that points Claude Code at the Weave Router.",
3
+ "version": "0.2.0",
4
+ "description": "One-command installer that points Claude Code, Codex, opencode, or pi at the Weave Router. For pi it also ships the routing extension, loaded via pi.extensions.",
5
5
  "bin": {
6
6
  "weave-router": "bin.js"
7
7
  },
8
+ "pi": {
9
+ "extensions": [
10
+ "./pi-router/src/index.ts"
11
+ ]
12
+ },
8
13
  "files": [
9
14
  "bin.js",
10
15
  "install.sh",
11
16
  "uninstall.sh",
12
17
  "cc-statusline.sh",
13
18
  "commands/",
19
+ "pi-router/",
14
20
  "README.md",
15
21
  "LICENSE"
16
22
  ],
@@ -20,6 +26,20 @@
20
26
  "engines": {
21
27
  "node": ">=18"
22
28
  },
29
+ "peerDependencies": {
30
+ "@mariozechner/pi-agent-core": "*",
31
+ "@mariozechner/pi-ai": "*",
32
+ "@mariozechner/pi-coding-agent": "*",
33
+ "@mariozechner/pi-tui": "*",
34
+ "typebox": "*"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@mariozechner/pi-agent-core": { "optional": true },
38
+ "@mariozechner/pi-ai": { "optional": true },
39
+ "@mariozechner/pi-coding-agent": { "optional": true },
40
+ "@mariozechner/pi-tui": { "optional": true },
41
+ "typebox": { "optional": true }
42
+ },
23
43
  "repository": {
24
44
  "type": "git",
25
45
  "url": "git+https://github.com/workweave/router.git",
@@ -33,6 +53,8 @@
33
53
  "claude",
34
54
  "claude-code",
35
55
  "anthropic",
36
- "llm-router"
56
+ "llm-router",
57
+ "pi",
58
+ "pi-package"
37
59
  ]
38
60
  }
@@ -0,0 +1,70 @@
1
+ # Weave Router pi extension
2
+
3
+ > Bundled inside the [`@workweave/router`](https://www.npmjs.com/package/@workweave/router) package — not published separately. `src/` here is the source of truth; `npm run prepack` copies it into the package.
4
+
5
+ A [pi](https://pi.dev) extension that routes every request through the
6
+ [WorkWeave Router](https://github.com/workweave/router) — a trained, per-request
7
+ LLM proxy that picks the most cost-efficient model that still solves each task.
8
+
9
+ Installed automatically by the Weave Router installer:
10
+
11
+ ```bash
12
+ WEAVE_ROUTER_KEY=rk_… npx -y @workweave/router --pi # user scope
13
+ WEAVE_ROUTER_KEY=rk_… npx -y @workweave/router --pi --local # local router (http://localhost:8080)
14
+ ```
15
+
16
+ That writes `~/.pi/agent/models.json` (the `weave` provider), adds
17
+ `npm:@workweave/router` to `~/.pi/agent/settings.json` `packages`, and stores
18
+ the key in `~/.pi/agent/.weave_router_key`. pi auto-installs `@workweave/router`
19
+ from npm on next start and loads this extension via its `pi.extensions` field.
20
+
21
+ ## What it does
22
+
23
+ - **Automatic model selection.** All pi traffic flows through the router, which
24
+ selects the model per request. You don't pick a model — the router does.
25
+ - **Per-process routing bias.** Static `x-weave-routing-*` knob headers bias the
26
+ router: quality on the main loop, speed + cheap on subagents, cheapest on
27
+ compaction.
28
+ - **Sticky sessions.** `metadata.user_id = "pi:<sessionId>"` pins the main loop
29
+ to one model for the session; subagents get their own pins.
30
+ - **`dispatch` tool — parallel, context-isolated subagents.** pi has none
31
+ natively. `dispatch` spawns child `pi` processes (read-only by default), runs
32
+ them concurrently, and returns only each subagent's final answer — intermediate
33
+ tool output stays in the child, so the main context stays small.
34
+ - **Routed-model display.** Shows the model the router actually chose
35
+ (`x-router-model`) in the status bar, and opts the request out of the router's
36
+ in-band routing badge (`X-Weave-Routing-Marker: off`) — pi can't render that
37
+ separate marker text block inline, and the status bar already conveys the model.
38
+ - **Safety backstop.** Blocks a few catastrophic shell commands (`rm -rf /`,
39
+ `mkfs`, `dd of=/dev/…`, fork bombs, force-push to main). Disable with
40
+ `WEAVE_NO_SAFETY=1`.
41
+
42
+ ## Configuration (environment)
43
+
44
+ | Variable | Default | Purpose |
45
+ |---|---|---|
46
+ | `WEAVE_ROUTER_URL` | `http://localhost:8080` | Router base URL (children inherit it) |
47
+ | `WEAVE_ROUTER_KEY` | — | Router key (else read from `.weave_router_key`) |
48
+ | `WEAVE_ROUTER_KEY_FILE` | `<agentDir>/.weave_router_key` | Override key file path |
49
+ | `WEAVE_USER_EMAIL` / `WEAVE_USER_NAME` | from `git config` | Identity headers for attribution |
50
+ | `WEAVE_PI_SUBAGENT_MODEL` | `claude-sonnet-4-6` | `weave/<model>` handle children launch with (router re-routes) |
51
+ | `WEAVE_PI_DISPATCH_CONCURRENCY` | `4` | Max concurrent subagents |
52
+ | `WEAVE_PI_SUBAGENT_TIMEOUT_MS` | `600000` | Per-subagent timeout |
53
+ | `WEAVE_PI_ALLOW_SUBAGENT_TOOLS` | unset | `1` lets `dispatch` grant subagents write/exec tools (bash, write, edit); default strips them |
54
+ | `WEAVE_ROUTING_ALPHA` / `…_SPEED_WEIGHT` / `…_OUTPUT_COST_RATIO` / `…_EXPECTED_OUTPUT_TOKENS` | role preset | Override individual routing knobs (main process only — children always use their role preset) |
55
+ | `WEAVE_NO_SAFETY` | unset | `1` disables the catastrophic-bash gate |
56
+ | `WEAVE_CHEAP_COMPACTION` | unset | `1` enables the (experimental) cheap-compaction path |
57
+
58
+ Internal: `WEAVE_PI_SUBAGENT=1` and `WEAVE_PI_SUBAGENT_ID` are set by `dispatch`
59
+ on child processes; don't set them yourself.
60
+
61
+ ## Billing
62
+
63
+ Routing through the router switches pi from Claude **subscription OAuth** to
64
+ **per-token** billing on the router deployment's key (or your BYOK key). BYOK
65
+ skips cross-provider failover; deployment-key billing is the default.
66
+
67
+ ## Notes
68
+
69
+ - Cheap compaction is currently a reserved flag — the handler defers to pi's
70
+ built-in compaction until the router-routed path is validated end-to-end.