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