@workweave/router 0.1.5 → 0.1.6
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 +15 -6
- package/cc-statusline.sh +25 -2
- package/install.sh +248 -24
- package/package.json +1 -1
- package/uninstall.sh +91 -7
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# @workweave/router
|
|
2
2
|
|
|
3
|
-
One command, anywhere, to point Claude Code or
|
|
3
|
+
One command, anywhere, to point Claude Code, Codex, or opencode at the Weave Router.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npx @workweave/router # interactive: pick Claude Code
|
|
6
|
+
npx @workweave/router # interactive: pick Claude Code / Codex / opencode, then scope
|
|
7
7
|
npx @workweave/router --claude # skip the picker, target Claude Code
|
|
8
8
|
npx @workweave/router --codex # skip the picker, target the OpenAI Codex CLI
|
|
9
|
-
npx @workweave/router --
|
|
9
|
+
npx @workweave/router --opencode # skip the picker, target opencode
|
|
10
|
+
npx @workweave/router --scope project # per-repo install, commit settings.json (or .codex/ / opencode.json)
|
|
10
11
|
npx @workweave/router --local # self-hosted via docker-compose (localhost:8080)
|
|
11
12
|
npx @workweave/router --base-url https://router.acme.internal
|
|
12
13
|
npx @workweave/router --non-interactive # reads $WEAVE_ROUTER_KEY, no prompts (defaults to claude)
|
|
@@ -23,6 +24,7 @@ Uninstall:
|
|
|
23
24
|
```bash
|
|
24
25
|
npx @workweave/router --uninstall # Claude Code, user scope
|
|
25
26
|
npx @workweave/router --uninstall --codex # Codex, user scope
|
|
27
|
+
npx @workweave/router --uninstall --opencode # opencode, user scope
|
|
26
28
|
npx @workweave/router --uninstall --scope project # Claude Code, inside the repo
|
|
27
29
|
npx @workweave/router --uninstall --codex --scope project
|
|
28
30
|
```
|
|
@@ -35,7 +37,7 @@ Node ≥ 18 — no `curl | sh`, no Git clone, no PATH fiddling. Everything the
|
|
|
35
37
|
shell installer documents (targets, scopes, flags, environment variables)
|
|
36
38
|
works identically here.
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
Three install targets:
|
|
39
41
|
|
|
40
42
|
- **Claude Code** (default) — patches `~/.claude/settings.json` (or
|
|
41
43
|
`<repo>/.claude/settings.json` with `--scope project`) so `claude` routes
|
|
@@ -47,6 +49,13 @@ Two install targets:
|
|
|
47
49
|
through to api.openai.com. The block lives between begin/end markers so
|
|
48
50
|
re-running the installer rewrites it cleanly and `--uninstall --codex`
|
|
49
51
|
removes it without touching the rest of your config.
|
|
52
|
+
- **opencode** (`--opencode`) — merges a `provider.weave` entry (backed by
|
|
53
|
+
opencode's built-in `@ai-sdk/anthropic` provider) into
|
|
54
|
+
`~/.config/opencode/opencode.json` (or `<repo>/opencode.json` with
|
|
55
|
+
`--scope project`). The router speaks the Anthropic Messages API
|
|
56
|
+
natively, so opencode talks to it unmodified. Re-install rewrites only
|
|
57
|
+
the managed `provider.weave` block; `--uninstall --opencode` strips it
|
|
58
|
+
and leaves your other providers and settings alone.
|
|
50
59
|
|
|
51
60
|
See the [main installer docs](https://github.com/workweave/router/tree/main/install)
|
|
52
61
|
for the full reference.
|
|
@@ -55,8 +64,8 @@ for the full reference.
|
|
|
55
64
|
|
|
56
65
|
- Node ≥ 18 (ships with `npx`)
|
|
57
66
|
- `bash` on PATH (macOS / Linux native; Windows needs Git Bash or WSL)
|
|
58
|
-
- `jq` on PATH — used by the Claude Code status line script
|
|
59
|
-
for the Codex path.
|
|
67
|
+
- `jq` on PATH — used by the Claude Code status line script and the
|
|
68
|
+
opencode JSON merge. Not required for the Codex path.
|
|
60
69
|
|
|
61
70
|
## Why npx
|
|
62
71
|
|
package/cc-statusline.sh
CHANGED
|
@@ -164,11 +164,15 @@ prices='{
|
|
|
164
164
|
"gpt-5.5-nano": 0.00015,
|
|
165
165
|
"gpt-5.5-pro": 0.03,
|
|
166
166
|
"minimax/minimax-m2.7": 0.0003,
|
|
167
|
+
"mistralai/mistral-small-2603": 0.0002,
|
|
167
168
|
"moonshotai/kimi-k2.5": 0.0006,
|
|
168
169
|
"moonshotai/kimi-k2.6": 0.00095,
|
|
169
170
|
"qwen/qwen3-235b-a22b-2507": 0.000071,
|
|
171
|
+
"qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
|
|
172
|
+
"qwen/qwen3-coder": 0.0009,
|
|
170
173
|
"qwen/qwen3-coder-next": 0.0005,
|
|
171
174
|
"qwen/qwen3-next-80b-a3b-instruct": 0.00015,
|
|
175
|
+
"qwen/qwen3.5-flash-02-23": 0.00005,
|
|
172
176
|
"qwen/qwen3.6-35b-a3b": 0.00015,
|
|
173
177
|
"xiaomi/mimo-v2.5": 0.0004,
|
|
174
178
|
"xiaomi/mimo-v2.5-pro": 0.001,
|
|
@@ -210,11 +214,15 @@ prices='{
|
|
|
210
214
|
"gpt-5.5-nano": 0.0006,
|
|
211
215
|
"gpt-5.5-pro": 0.12,
|
|
212
216
|
"minimax/minimax-m2.7": 0.0012,
|
|
217
|
+
"mistralai/mistral-small-2603": 0.0006,
|
|
213
218
|
"moonshotai/kimi-k2.5": 0.003,
|
|
214
219
|
"moonshotai/kimi-k2.6": 0.004,
|
|
215
220
|
"qwen/qwen3-235b-a22b-2507": 0.000463,
|
|
221
|
+
"qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
|
|
222
|
+
"qwen/qwen3-coder": 0.0027,
|
|
216
223
|
"qwen/qwen3-coder-next": 0.0012,
|
|
217
224
|
"qwen/qwen3-next-80b-a3b-instruct": 0.0012,
|
|
225
|
+
"qwen/qwen3.5-flash-02-23": 0.00015,
|
|
218
226
|
"qwen/qwen3.6-35b-a3b": 0.00095,
|
|
219
227
|
"xiaomi/mimo-v2.5": 0.002,
|
|
220
228
|
"xiaomi/mimo-v2.5-pro": 0.003,
|
|
@@ -274,9 +282,24 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
|
|
|
274
282
|
# The marker regex tolerates the optional "(<provider>)" segment and a
|
|
275
283
|
# `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
|
|
276
284
|
# against context-tiered or dated model ids still parse cleanly.
|
|
285
|
+
#
|
|
286
|
+
# Dedup note: CC writes one JSONL entry per *content block* in an
|
|
287
|
+
# assistant turn (text, text, tool_use → 3 entries), and every entry
|
|
288
|
+
# carries the same `message.usage`. Summing per-entry triple-counts the
|
|
289
|
+
# turn. We dedupe on (message.id, message.usage) before summing:
|
|
290
|
+
# * For native Anthropic upstreams message.id is unique per turn, so
|
|
291
|
+
# this collapses the content-block fan-out cleanly.
|
|
292
|
+
# * For non-Anthropic upstreams that round-trip through the router's
|
|
293
|
+
# translator, message.id can be a constant placeholder
|
|
294
|
+
# ("msg_translated"); usage still differs per turn (input_tokens
|
|
295
|
+
# grows), so the composite key keeps turns distinct. Two turns with
|
|
296
|
+
# byte-identical id AND usage would still collapse, but that's a
|
|
297
|
+
# genuine retry/duplicate we want to drop.
|
|
277
298
|
read -r session_savings tot_in tot_out tot_cache_read tot_cache_write < <(
|
|
278
|
-
jq -
|
|
279
|
-
select(.type=="assistant") |
|
|
299
|
+
jq -rs --argjson p "$prices" --arg requested "$requested_norm" '
|
|
300
|
+
[.[] | select(.type=="assistant")] |
|
|
301
|
+
unique_by([.message.id, .message.usage]) |
|
|
302
|
+
.[] |
|
|
280
303
|
.message as $m |
|
|
281
304
|
($m.model // "" | sub("\\[[^]]*\\]$"; "") | sub("-[0-9]{8}$"; "")) as $rm |
|
|
282
305
|
{
|
package/install.sh
CHANGED
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# Weave Router installer for Claude Code and
|
|
3
|
+
# Weave Router installer for Claude Code, Codex, and opencode.
|
|
4
4
|
#
|
|
5
|
-
# Configures Claude Code (default)
|
|
6
|
-
# permanently route through the Weave Router.
|
|
7
|
-
# the router base URL, router auth header,
|
|
8
|
-
# Code's settings.json. For Codex it writes
|
|
9
|
-
# entry plus `model_provider = "weave"` into
|
|
10
|
-
# block delimited by markers
|
|
5
|
+
# Configures Claude Code (default), the OpenAI Codex CLI (`--codex`), or
|
|
6
|
+
# opencode (`--opencode`) to permanently route through the Weave Router.
|
|
7
|
+
# For Claude Code this writes the router base URL, router auth header,
|
|
8
|
+
# and a status line into Claude Code's settings.json. For Codex it writes
|
|
9
|
+
# a `model_providers.weave` entry plus `model_provider = "weave"` into
|
|
10
|
+
# ~/.codex/config.toml (managed block delimited by markers). For opencode
|
|
11
|
+
# it merges a `provider.weave` block (anthropic-compatible) into
|
|
12
|
+
# opencode.json — since the file is JSON, install/uninstall are structural
|
|
13
|
+
# (jq) rather than marker-delimited.
|
|
11
14
|
#
|
|
12
|
-
# Two scopes (apply to
|
|
15
|
+
# Two scopes (apply to all targets):
|
|
13
16
|
# - user (default): ~/.claude/settings.json + ~/.weave/cc-statusline.sh
|
|
14
17
|
# ~/.codex/config.toml (with --codex)
|
|
18
|
+
# ~/.config/opencode/opencode.json (with --opencode)
|
|
15
19
|
# - project: <repo>/.claude/settings.json + <repo>/.claude/cc-statusline.sh
|
|
16
20
|
# <repo>/.codex/config.toml (with --codex)
|
|
21
|
+
# <repo>/opencode.json (with --opencode)
|
|
17
22
|
#
|
|
18
23
|
# Or pass --dir to install into any directory:
|
|
19
24
|
# - dir: <dir>/.claude/settings.json + <dir>/.claude/cc-statusline.sh
|
|
20
25
|
# <dir>/.codex/config.toml (with --codex)
|
|
26
|
+
# <dir>/opencode.json (with --opencode)
|
|
21
27
|
#
|
|
22
28
|
# Usage:
|
|
23
|
-
# npx @workweave/router # interactive picker (Claude Code
|
|
29
|
+
# npx @workweave/router # interactive picker (Claude Code, Codex, opencode)
|
|
24
30
|
# npx @workweave/router --claude # skip the picker, target Claude Code
|
|
25
31
|
# npx @workweave/router --codex # skip the picker, target the OpenAI Codex CLI
|
|
32
|
+
# npx @workweave/router --opencode # skip the picker, target opencode
|
|
26
33
|
# npx @workweave/router --scope project # commit-with-team install
|
|
27
34
|
# npx @workweave/router --dir /tmp/my-sandbox # isolated throwaway install
|
|
28
35
|
# npx @workweave/router --local # local router on localhost:8080
|
|
@@ -47,11 +54,14 @@ non_interactive="false"
|
|
|
47
54
|
quiet="false"
|
|
48
55
|
router_key_header="X-Weave-Router-Key"
|
|
49
56
|
# Target tool whose config we patch. "claude" (default) writes Claude Code
|
|
50
|
-
# settings.json; "codex" writes ~/.codex/config.toml
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
57
|
+
# settings.json; "codex" writes ~/.codex/config.toml; "opencode" merges a
|
|
58
|
+
# provider block into opencode.json. Each target carries its own
|
|
59
|
+
# credential-passthrough story in the router: Claude Code's logged-in
|
|
60
|
+
# Anthropic key flows through unchanged, Codex's `OPENAI_API_KEY` flows
|
|
61
|
+
# through via the same header path, and opencode talks to the router via
|
|
62
|
+
# its anthropic-compatible API surface. target_explicit tracks whether
|
|
63
|
+
# --claude / --codex / --opencode was passed so an interactive run can
|
|
64
|
+
# prompt for the choice.
|
|
55
65
|
target="claude"
|
|
56
66
|
target_explicit="false"
|
|
57
67
|
|
|
@@ -97,8 +107,9 @@ print_banner() {
|
|
|
97
107
|
[ "$tty_out" = "true" ] || return 0
|
|
98
108
|
local target_label
|
|
99
109
|
case "$target" in
|
|
100
|
-
codex)
|
|
101
|
-
|
|
110
|
+
codex) target_label="Codex installer" ;;
|
|
111
|
+
opencode) target_label="opencode installer" ;;
|
|
112
|
+
*) target_label="Claude Code installer" ;;
|
|
102
113
|
esac
|
|
103
114
|
printf '\n'
|
|
104
115
|
printf '%s ╦ ╦╔═╗╔═╗╦ ╦╔═╗%s\n' "$C_BRAND" "$C_RESET"
|
|
@@ -473,6 +484,92 @@ TOML
|
|
|
473
484
|
chmod 600 "$config_file"
|
|
474
485
|
}
|
|
475
486
|
|
|
487
|
+
# write_opencode_config merges a managed `provider.weave` entry into opencode's
|
|
488
|
+
# opencode.json (anthropic-compatible — the router speaks the Anthropic
|
|
489
|
+
# Messages API natively, so opencode's bundled @ai-sdk/anthropic provider
|
|
490
|
+
# works unmodified). Re-running rewrites the block in-place via jq; uninstall
|
|
491
|
+
# strips it the same way. We also set `model` at the top level so a fresh
|
|
492
|
+
# `opencode` invocation picks the router by default; if the user has set
|
|
493
|
+
# their own model already, we leave it alone.
|
|
494
|
+
#
|
|
495
|
+
# Usage: write_opencode_config <config_file_path> <base_url> <api_key> [user_email] [user_name]
|
|
496
|
+
write_opencode_config() {
|
|
497
|
+
local config_file="$1"
|
|
498
|
+
local block_url="$2"
|
|
499
|
+
local block_key="$3"
|
|
500
|
+
local block_email="${4:-}"
|
|
501
|
+
local block_name="${5:-}"
|
|
502
|
+
|
|
503
|
+
# Build the headers object piecewise so empty email/name vanish from the
|
|
504
|
+
# final JSON. opencode forwards the `headers` map verbatim to the upstream
|
|
505
|
+
# provider, so the router sees the same X-Weave-* triplet here that it
|
|
506
|
+
# would from Claude Code or Codex. The X-App tag lets router telemetry
|
|
507
|
+
# attribute traffic to opencode specifically.
|
|
508
|
+
local headers_json
|
|
509
|
+
headers_json="$(jq -n \
|
|
510
|
+
--arg key "$block_key" \
|
|
511
|
+
--arg email "$block_email" \
|
|
512
|
+
--arg name "$block_name" '
|
|
513
|
+
{"X-Weave-Router-Key": $key, "X-App": "opencode"}
|
|
514
|
+
| (if $email != "" then . + {"X-Weave-User-Email": $email} else . end)
|
|
515
|
+
| (if $name != "" then . + {"X-Weave-User-Name": $name } else . end)
|
|
516
|
+
')"
|
|
517
|
+
|
|
518
|
+
# Headline models we surface in opencode's picker. The router re-routes
|
|
519
|
+
# each request anyway, so this list is mostly UX — what shows up when the
|
|
520
|
+
# user runs /models inside opencode. Keep it short and Anthropic-shaped
|
|
521
|
+
# so the bundled @ai-sdk/anthropic provider can request them.
|
|
522
|
+
#
|
|
523
|
+
# apiKey is set to the router key as well as planted in headers. opencode's
|
|
524
|
+
# @ai-sdk/anthropic provider treats apiKey as required at config-parse time
|
|
525
|
+
# and otherwise falls back to ANTHROPIC_API_KEY from the environment;
|
|
526
|
+
# without this, a user who's never had an Anthropic key in their shell hits
|
|
527
|
+
# a startup error before the router ever sees a request. The router itself
|
|
528
|
+
# ignores the value (auth runs off X-Weave-Router-Key); apiKey here is just
|
|
529
|
+
# a placeholder that satisfies the SDK's "is auth configured" check.
|
|
530
|
+
local block
|
|
531
|
+
block="$(jq -n \
|
|
532
|
+
--arg url "$block_url/v1" \
|
|
533
|
+
--arg key "$block_key" \
|
|
534
|
+
--argjson headers "$headers_json" '
|
|
535
|
+
{
|
|
536
|
+
npm: "@ai-sdk/anthropic",
|
|
537
|
+
name: "Weave Router",
|
|
538
|
+
options: { apiKey: $key, baseURL: $url, headers: $headers },
|
|
539
|
+
models: {
|
|
540
|
+
"claude-opus-4-7": { name: "Claude Opus 4.7 (via Weave Router)" },
|
|
541
|
+
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6 (via Weave Router)" },
|
|
542
|
+
"claude-haiku-4-5": { name: "Claude Haiku 4.5 (via Weave Router)" }
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
')"
|
|
546
|
+
|
|
547
|
+
# Merge into any existing opencode.json. We always overwrite provider.weave
|
|
548
|
+
# so re-install reflects the latest key/identity, but we leave the rest of
|
|
549
|
+
# the file (other providers, mcp, agent settings) untouched. Top-level
|
|
550
|
+
# `model` is only set when the user hasn't already picked one.
|
|
551
|
+
local merged
|
|
552
|
+
if [ -f "$config_file" ]; then
|
|
553
|
+
merged="$(jq --argjson block "$block" '
|
|
554
|
+
.provider = ((.provider // {}) | .weave = $block)
|
|
555
|
+
| (if (.model // "") == "" then .model = "weave/claude-sonnet-4-6" else . end)
|
|
556
|
+
| (.["$schema"] //= "https://opencode.ai/config.json")
|
|
557
|
+
' "$config_file")"
|
|
558
|
+
else
|
|
559
|
+
merged="$(jq -n --argjson block "$block" '
|
|
560
|
+
{
|
|
561
|
+
"$schema": "https://opencode.ai/config.json",
|
|
562
|
+
model: "weave/claude-sonnet-4-6",
|
|
563
|
+
provider: { weave: $block }
|
|
564
|
+
}
|
|
565
|
+
')"
|
|
566
|
+
fi
|
|
567
|
+
printf '%s\n' "$merged" >"$config_file"
|
|
568
|
+
# 0600: the file holds a router key. Even at user scope, mode 644 would
|
|
569
|
+
# leak the key to any local user on a shared box.
|
|
570
|
+
chmod 600 "$config_file"
|
|
571
|
+
}
|
|
572
|
+
|
|
476
573
|
# resolve_user_name mirrors resolve_user_email but for display name. Priority:
|
|
477
574
|
# WEAVE_USER_NAME env override → git config user.name → empty. We don't
|
|
478
575
|
# prompt for name independently: if email prompting yielded nothing, name
|
|
@@ -575,9 +672,13 @@ while [ $# -gt 0 ]; do
|
|
|
575
672
|
--codex)
|
|
576
673
|
target="codex"; target_explicit="true"; shift
|
|
577
674
|
;;
|
|
675
|
+
--opencode)
|
|
676
|
+
target="opencode"; target_explicit="true"; shift
|
|
677
|
+
;;
|
|
578
678
|
--claude)
|
|
579
|
-
# No-op selector for symmetry with --codex. Useful in
|
|
580
|
-
# want to skip the interactive picker without depending
|
|
679
|
+
# No-op selector for symmetry with --codex / --opencode. Useful in
|
|
680
|
+
# pipelines that want to skip the interactive picker without depending
|
|
681
|
+
# on the default.
|
|
581
682
|
target="claude"; target_explicit="true"; shift
|
|
582
683
|
;;
|
|
583
684
|
-h|--help)
|
|
@@ -607,11 +708,13 @@ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r
|
|
|
607
708
|
printf "%sInstall target:%s\n" "$C_BOLD" "$C_RESET"
|
|
608
709
|
printf " %s1)%s Claude Code %s— patches ~/.claude/settings.json (or <repo>/.claude/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
|
|
609
710
|
printf " %s2)%s Codex %s— patches ~/.codex/config.toml (or <repo>/.codex/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
|
|
610
|
-
printf "
|
|
711
|
+
printf " %s3)%s opencode %s— patches ~/.config/opencode/opencode.json (or <repo>/opencode.json)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
|
|
712
|
+
printf "Choose %s[1/2/3]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
|
|
611
713
|
read -r target_choice </dev/tty || target_choice=""
|
|
612
714
|
case "${target_choice:-1}" in
|
|
613
715
|
1|""|claude|c|C) target="claude" ;;
|
|
614
716
|
2|codex|x|X) target="codex" ;;
|
|
717
|
+
3|opencode|o|O) target="opencode" ;;
|
|
615
718
|
*) err "invalid choice: $target_choice"; exit 2 ;;
|
|
616
719
|
esac
|
|
617
720
|
fi
|
|
@@ -635,6 +738,19 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
|
|
|
635
738
|
scope_project_path="<repo>/.codex/"
|
|
636
739
|
scope_cli_label="codex"
|
|
637
740
|
;;
|
|
741
|
+
opencode)
|
|
742
|
+
# Match the actual install path, which honors XDG_CONFIG_HOME. Showing a
|
|
743
|
+
# hardcoded "~/.config/opencode/" here lied to users with a custom
|
|
744
|
+
# $XDG_CONFIG_HOME — they'd see one path in the prompt and the installer
|
|
745
|
+
# would write to another.
|
|
746
|
+
if [ -n "${XDG_CONFIG_HOME:-}" ]; then
|
|
747
|
+
scope_user_path="$XDG_CONFIG_HOME/opencode/"
|
|
748
|
+
else
|
|
749
|
+
scope_user_path="~/.config/opencode/"
|
|
750
|
+
fi
|
|
751
|
+
scope_project_path="<repo>/opencode.json"
|
|
752
|
+
scope_cli_label="opencode"
|
|
753
|
+
;;
|
|
638
754
|
*)
|
|
639
755
|
scope_user_path="~/.claude/"
|
|
640
756
|
scope_project_path="<repo>/.claude/"
|
|
@@ -678,8 +794,9 @@ fi
|
|
|
678
794
|
[ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
|
|
679
795
|
|
|
680
796
|
# Codex install only writes a TOML file (managed via awk) so jq isn't needed.
|
|
681
|
-
# Claude Code's settings.json
|
|
682
|
-
|
|
797
|
+
# Claude Code's settings.json and opencode's opencode.json patching both use
|
|
798
|
+
# jq to deep-merge / structurally rewrite JSON.
|
|
799
|
+
if [ "$target" = "claude" ] || [ "$target" = "opencode" ]; then
|
|
683
800
|
require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
|
|
684
801
|
fi
|
|
685
802
|
require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
|
|
@@ -697,6 +814,12 @@ case "$target" in
|
|
|
697
814
|
warn "Continuing — config.toml will be written and will take effect once Codex is installed."
|
|
698
815
|
fi
|
|
699
816
|
;;
|
|
817
|
+
opencode)
|
|
818
|
+
if ! command -v opencode >/dev/null 2>&1; then
|
|
819
|
+
warn "'opencode' not found on PATH. Install from https://opencode.ai (or 'npm install -g opencode-ai'), then re-run this script."
|
|
820
|
+
warn "Continuing — opencode.json will be written and will take effect once opencode is installed."
|
|
821
|
+
fi
|
|
822
|
+
;;
|
|
700
823
|
esac
|
|
701
824
|
|
|
702
825
|
script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
|
|
@@ -767,7 +890,7 @@ if [ "$target" = "claude" ]; then
|
|
|
767
890
|
fi
|
|
768
891
|
|
|
769
892
|
mkdir -p "$settings_dir" "$statusline_dir"
|
|
770
|
-
|
|
893
|
+
elif [ "$target" = "codex" ]; then
|
|
771
894
|
# Codex CLI reads config from ~/.codex/config.toml by default. For project
|
|
772
895
|
# scope we write to <repo>/.codex/config.toml; the user invokes Codex with
|
|
773
896
|
# `CODEX_HOME=<repo>/.codex codex` (or runs from the repo if Codex auto-
|
|
@@ -782,6 +905,34 @@ else
|
|
|
782
905
|
fi
|
|
783
906
|
|
|
784
907
|
mkdir -p "$codex_dir"
|
|
908
|
+
else
|
|
909
|
+
# opencode discovers config in this order: $XDG_CONFIG_HOME/opencode/opencode.json
|
|
910
|
+
# (or ~/.config/opencode/opencode.json) for user scope, and opencode.json /
|
|
911
|
+
# opencode.jsonc walked up from CWD for project scope. We standardize on
|
|
912
|
+
# opencode.json at the repo root for project scope (the option teammates can
|
|
913
|
+
# commit) and the XDG path for user scope. The router key is embedded so
|
|
914
|
+
# opencode.json goes in .gitignore for project scope, same as Codex.
|
|
915
|
+
case "$scope" in
|
|
916
|
+
user)
|
|
917
|
+
opencode_dir="${XDG_CONFIG_HOME:-$settings_base/.config}/opencode"
|
|
918
|
+
;;
|
|
919
|
+
project)
|
|
920
|
+
opencode_dir="$settings_base"
|
|
921
|
+
;;
|
|
922
|
+
esac
|
|
923
|
+
# --dir overrides both scopes: drop opencode.json straight into <dir>/ so
|
|
924
|
+
# the sandbox is self-contained (mirrors how --dir behaves for Codex).
|
|
925
|
+
if [ -n "$install_dir" ]; then
|
|
926
|
+
opencode_dir="$install_dir"
|
|
927
|
+
fi
|
|
928
|
+
opencode_config_file="$opencode_dir/opencode.json"
|
|
929
|
+
|
|
930
|
+
if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
|
|
931
|
+
refuse_if_symlink "$opencode_dir"
|
|
932
|
+
refuse_if_symlink "$opencode_config_file"
|
|
933
|
+
fi
|
|
934
|
+
|
|
935
|
+
mkdir -p "$opencode_dir"
|
|
785
936
|
fi
|
|
786
937
|
|
|
787
938
|
# ---------- token handling ----------
|
|
@@ -943,6 +1094,56 @@ if [ "$target" = "codex" ]; then
|
|
|
943
1094
|
exit 0
|
|
944
1095
|
fi
|
|
945
1096
|
|
|
1097
|
+
# ---------- opencode install path (dispatch + exit before the Claude-only writes) ----------
|
|
1098
|
+
|
|
1099
|
+
if [ "$target" = "opencode" ]; then
|
|
1100
|
+
write_opencode_config "$opencode_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
|
|
1101
|
+
ok "opencode config written to $opencode_config_file"
|
|
1102
|
+
|
|
1103
|
+
# Project scope: the per-teammate config carries the router key, so it
|
|
1104
|
+
# stays out of git. Same reasoning as the Codex path — base URL is shared,
|
|
1105
|
+
# but the key is per-person.
|
|
1106
|
+
if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
|
|
1107
|
+
gitignore="$git_root/.gitignore"
|
|
1108
|
+
refuse_if_symlink "$gitignore"
|
|
1109
|
+
for entry in \
|
|
1110
|
+
"opencode.json"
|
|
1111
|
+
do
|
|
1112
|
+
if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
|
|
1113
|
+
printf '%s\n' "$entry" >>"$gitignore"
|
|
1114
|
+
fi
|
|
1115
|
+
done
|
|
1116
|
+
ok "Updated $gitignore (ignored opencode.json)"
|
|
1117
|
+
fi
|
|
1118
|
+
|
|
1119
|
+
# Post-install verification: same probes the Claude/Codex paths run.
|
|
1120
|
+
if [ "$quiet" != "true" ]; then
|
|
1121
|
+
if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
|
|
1122
|
+
warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
|
|
1123
|
+
fi
|
|
1124
|
+
fi
|
|
1125
|
+
|
|
1126
|
+
if [ -n "$api_key" ]; then
|
|
1127
|
+
validate_opencode_key() {
|
|
1128
|
+
printf '%s: %s\n' "$router_key_header" "$api_key" \
|
|
1129
|
+
| curl -fsS --max-time 5 --header @- "$base_url/validate"
|
|
1130
|
+
}
|
|
1131
|
+
if ! spin "Validating API key" validate_opencode_key; then
|
|
1132
|
+
warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
|
|
1133
|
+
fi
|
|
1134
|
+
fi
|
|
1135
|
+
|
|
1136
|
+
printf "\n"
|
|
1137
|
+
printf "%s✓%s %s%sWeave Router installed for opencode.%s\n" \
|
|
1138
|
+
"$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
|
|
1139
|
+
if [ -n "$install_dir" ]; then
|
|
1140
|
+
# --dir installs land outside opencode's discovery roots, so the caller
|
|
1141
|
+
# has to point opencode at the file explicitly.
|
|
1142
|
+
info "Run opencode with OPENCODE_CONFIG=$opencode_config_file opencode."
|
|
1143
|
+
fi
|
|
1144
|
+
exit 0
|
|
1145
|
+
fi
|
|
1146
|
+
|
|
946
1147
|
# ---------- write the statusline script ----------
|
|
947
1148
|
|
|
948
1149
|
cat > "$statusline_file" << 'STATUSLINE_EOF'
|
|
@@ -1112,11 +1313,15 @@ prices='{
|
|
|
1112
1313
|
"gpt-5.5-nano": 0.00015,
|
|
1113
1314
|
"gpt-5.5-pro": 0.03,
|
|
1114
1315
|
"minimax/minimax-m2.7": 0.0003,
|
|
1316
|
+
"mistralai/mistral-small-2603": 0.0002,
|
|
1115
1317
|
"moonshotai/kimi-k2.5": 0.0006,
|
|
1116
1318
|
"moonshotai/kimi-k2.6": 0.00095,
|
|
1117
1319
|
"qwen/qwen3-235b-a22b-2507": 0.000071,
|
|
1320
|
+
"qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
|
|
1321
|
+
"qwen/qwen3-coder": 0.0009,
|
|
1118
1322
|
"qwen/qwen3-coder-next": 0.0005,
|
|
1119
1323
|
"qwen/qwen3-next-80b-a3b-instruct": 0.00015,
|
|
1324
|
+
"qwen/qwen3.5-flash-02-23": 0.00005,
|
|
1120
1325
|
"qwen/qwen3.6-35b-a3b": 0.00015,
|
|
1121
1326
|
"xiaomi/mimo-v2.5": 0.0004,
|
|
1122
1327
|
"xiaomi/mimo-v2.5-pro": 0.001,
|
|
@@ -1158,11 +1363,15 @@ prices='{
|
|
|
1158
1363
|
"gpt-5.5-nano": 0.0006,
|
|
1159
1364
|
"gpt-5.5-pro": 0.12,
|
|
1160
1365
|
"minimax/minimax-m2.7": 0.0012,
|
|
1366
|
+
"mistralai/mistral-small-2603": 0.0006,
|
|
1161
1367
|
"moonshotai/kimi-k2.5": 0.003,
|
|
1162
1368
|
"moonshotai/kimi-k2.6": 0.004,
|
|
1163
1369
|
"qwen/qwen3-235b-a22b-2507": 0.000463,
|
|
1370
|
+
"qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
|
|
1371
|
+
"qwen/qwen3-coder": 0.0027,
|
|
1164
1372
|
"qwen/qwen3-coder-next": 0.0012,
|
|
1165
1373
|
"qwen/qwen3-next-80b-a3b-instruct": 0.0012,
|
|
1374
|
+
"qwen/qwen3.5-flash-02-23": 0.00015,
|
|
1166
1375
|
"qwen/qwen3.6-35b-a3b": 0.00095,
|
|
1167
1376
|
"xiaomi/mimo-v2.5": 0.002,
|
|
1168
1377
|
"xiaomi/mimo-v2.5-pro": 0.003,
|
|
@@ -1222,9 +1431,24 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
|
|
|
1222
1431
|
# The marker regex tolerates the optional "(<provider>)" segment and a
|
|
1223
1432
|
# `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
|
|
1224
1433
|
# against context-tiered or dated model ids still parse cleanly.
|
|
1434
|
+
#
|
|
1435
|
+
# Dedup note: CC writes one JSONL entry per *content block* in an
|
|
1436
|
+
# assistant turn (text, text, tool_use → 3 entries), and every entry
|
|
1437
|
+
# carries the same `message.usage`. Summing per-entry triple-counts the
|
|
1438
|
+
# turn. We dedupe on (message.id, message.usage) before summing:
|
|
1439
|
+
# * For native Anthropic upstreams message.id is unique per turn, so
|
|
1440
|
+
# this collapses the content-block fan-out cleanly.
|
|
1441
|
+
# * For non-Anthropic upstreams that round-trip through the router's
|
|
1442
|
+
# translator, message.id can be a constant placeholder
|
|
1443
|
+
# ("msg_translated"); usage still differs per turn (input_tokens
|
|
1444
|
+
# grows), so the composite key keeps turns distinct. Two turns with
|
|
1445
|
+
# byte-identical id AND usage would still collapse, but that's a
|
|
1446
|
+
# genuine retry/duplicate we want to drop.
|
|
1225
1447
|
read -r session_savings tot_in tot_out tot_cache_read tot_cache_write < <(
|
|
1226
|
-
jq -
|
|
1227
|
-
select(.type=="assistant") |
|
|
1448
|
+
jq -rs --argjson p "$prices" --arg requested "$requested_norm" '
|
|
1449
|
+
[.[] | select(.type=="assistant")] |
|
|
1450
|
+
unique_by([.message.id, .message.usage]) |
|
|
1451
|
+
.[] |
|
|
1228
1452
|
.message as $m |
|
|
1229
1453
|
($m.model // "" | sub("\\[[^]]*\\]$"; "") | sub("-[0-9]{8}$"; "")) as $rm |
|
|
1230
1454
|
{
|
package/package.json
CHANGED
package/uninstall.sh
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# Weave Router uninstaller for Claude Code and
|
|
3
|
+
# Weave Router uninstaller for Claude Code, Codex, and opencode.
|
|
4
4
|
#
|
|
5
5
|
# Default target is Claude Code: removes the env vars, statusLine, and local
|
|
6
6
|
# router auth that install.sh added; leaves the rest of settings.json
|
|
7
7
|
# untouched. Pass --codex to strip the managed [model_providers.weave]
|
|
8
8
|
# block (and the matching top-level `model_provider`) from Codex's
|
|
9
|
-
# config.toml — anything outside the markers is preserved.
|
|
9
|
+
# config.toml — anything outside the markers is preserved. Pass --opencode
|
|
10
|
+
# to strip the `provider.weave` block (and the top-level `model` key when
|
|
11
|
+
# it points at the router) from opencode.json; other providers and user
|
|
12
|
+
# settings are preserved.
|
|
10
13
|
#
|
|
11
14
|
# Usage:
|
|
12
15
|
# npx @workweave/router --uninstall # Claude Code, user scope
|
|
13
16
|
# npx @workweave/router --uninstall --codex # Codex, user scope
|
|
17
|
+
# npx @workweave/router --uninstall --opencode # opencode, user scope
|
|
14
18
|
# npx @workweave/router --uninstall --scope project # run inside the repo
|
|
15
19
|
# npx @workweave/router --uninstall --dir /tmp/test # --dir alone (user scope, .weave/)
|
|
16
20
|
# npx @workweave/router --uninstall --scope project --dir /tmp # --dir + project scope (.claude/)
|
|
@@ -52,10 +56,14 @@ while [ $# -gt 0 ]; do
|
|
|
52
56
|
--codex)
|
|
53
57
|
target="codex"; shift
|
|
54
58
|
;;
|
|
59
|
+
--opencode)
|
|
60
|
+
target="opencode"; shift
|
|
61
|
+
;;
|
|
55
62
|
--claude)
|
|
56
|
-
# No-op selector for symmetry with --codex and install.sh's
|
|
57
|
-
# Lets `./install.sh --uninstall --claude` (which forwards
|
|
58
|
-
# args here) succeed instead of hitting the unknown-flag
|
|
63
|
+
# No-op selector for symmetry with --codex / --opencode and install.sh's
|
|
64
|
+
# --claude. Lets `./install.sh --uninstall --claude` (which forwards
|
|
65
|
+
# remaining args here) succeed instead of hitting the unknown-flag
|
|
66
|
+
# catch-all.
|
|
59
67
|
target="claude"; shift
|
|
60
68
|
;;
|
|
61
69
|
-h|--help)
|
|
@@ -69,8 +77,8 @@ while [ $# -gt 0 ]; do
|
|
|
69
77
|
esac
|
|
70
78
|
done
|
|
71
79
|
|
|
72
|
-
if [ "$target" = "claude" ] && ! command -v jq >/dev/null 2>&1; then
|
|
73
|
-
err "jq is required for the
|
|
80
|
+
if { [ "$target" = "claude" ] || [ "$target" = "opencode" ]; } && ! command -v jq >/dev/null 2>&1; then
|
|
81
|
+
err "jq is required for the $target uninstall path."
|
|
74
82
|
exit 1
|
|
75
83
|
fi
|
|
76
84
|
|
|
@@ -97,6 +105,82 @@ strip_codex_block() {
|
|
|
97
105
|
mv "$tmp" "$config_file"
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
# ---------- opencode uninstall path ----------
|
|
109
|
+
|
|
110
|
+
if [ "$target" = "opencode" ]; then
|
|
111
|
+
# Resolve opencode_config_file based on scope/dir. Mirrors install.sh so
|
|
112
|
+
# an `install --opencode --scope project` is exactly reversed by an
|
|
113
|
+
# `uninstall --opencode --scope project`.
|
|
114
|
+
if [ -n "$install_dir" ]; then
|
|
115
|
+
install_dir="$(cd "$install_dir" 2>/dev/null && pwd || echo "$install_dir")"
|
|
116
|
+
opencode_dir="$install_dir"
|
|
117
|
+
refuse_if_symlink "$opencode_dir"
|
|
118
|
+
elif [ "$scope" = "user" ]; then
|
|
119
|
+
opencode_dir="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
|
|
120
|
+
else
|
|
121
|
+
# Project scope: same prompt + git-root fallback as install.sh.
|
|
122
|
+
project_dir=""
|
|
123
|
+
if [ "$scope_explicit" = "false" ] && [ -r /dev/tty ]; then
|
|
124
|
+
default_project_dir="$(pwd)"
|
|
125
|
+
printf "Project directory to uninstall from [default: %s]: " "$default_project_dir"
|
|
126
|
+
read -r project_dir_choice </dev/tty || project_dir_choice=""
|
|
127
|
+
project_dir="${project_dir_choice:-$default_project_dir}"
|
|
128
|
+
case "$project_dir" in
|
|
129
|
+
"~") project_dir="$HOME" ;;
|
|
130
|
+
"~/"*) project_dir="$HOME/${project_dir#~/}" ;;
|
|
131
|
+
esac
|
|
132
|
+
if [ ! -d "$project_dir" ]; then
|
|
133
|
+
err "directory does not exist: $project_dir"
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
136
|
+
project_dir="$(cd "$project_dir" && pwd)"
|
|
137
|
+
fi
|
|
138
|
+
if [ -n "${project_dir:-}" ]; then
|
|
139
|
+
opencode_dir="$project_dir"
|
|
140
|
+
else
|
|
141
|
+
if ! git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
|
|
142
|
+
err "--scope project must be run inside a git repo, or use --dir <path>."
|
|
143
|
+
exit 1
|
|
144
|
+
fi
|
|
145
|
+
opencode_dir="$git_root"
|
|
146
|
+
fi
|
|
147
|
+
refuse_if_symlink "$opencode_dir"
|
|
148
|
+
fi
|
|
149
|
+
opencode_config_file="$opencode_dir/opencode.json"
|
|
150
|
+
refuse_if_symlink "$opencode_config_file"
|
|
151
|
+
|
|
152
|
+
if [ -f "$opencode_config_file" ]; then
|
|
153
|
+
# Strip `provider.weave` and any router-pointing top-level model. Other
|
|
154
|
+
# providers, user-set models that don't reference the router, and any
|
|
155
|
+
# unrelated keys are preserved.
|
|
156
|
+
cleaned="$(jq '
|
|
157
|
+
(if .provider.weave then del(.provider.weave) else . end)
|
|
158
|
+
| (if (.provider // {}) == {} then del(.provider) else . end)
|
|
159
|
+
| (if (.model // "" | tostring | startswith("weave/")) then del(.model) else . end)
|
|
160
|
+
' "$opencode_config_file")"
|
|
161
|
+
printf '%s\n' "$cleaned" >"$opencode_config_file"
|
|
162
|
+
|
|
163
|
+
# If only the $schema marker remains (or the file is empty), drop the
|
|
164
|
+
# file entirely so we don't leave a one-key artifact.
|
|
165
|
+
remaining_keys="$(jq -r 'del(."$schema") | keys | length' "$opencode_config_file" 2>/dev/null || echo 0)"
|
|
166
|
+
if [ "$remaining_keys" = "0" ]; then
|
|
167
|
+
rm -f "$opencode_config_file"
|
|
168
|
+
ok "Removed empty $opencode_config_file"
|
|
169
|
+
else
|
|
170
|
+
ok "Cleaned $opencode_config_file"
|
|
171
|
+
fi
|
|
172
|
+
else
|
|
173
|
+
info "No opencode config at $opencode_config_file (already uninstalled?)"
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
if [ -n "$install_dir" ]; then
|
|
177
|
+
ok "Weave Router uninstalled from $install_dir (opencode)."
|
|
178
|
+
else
|
|
179
|
+
ok "Weave Router uninstalled (opencode, scope=$scope)."
|
|
180
|
+
fi
|
|
181
|
+
exit 0
|
|
182
|
+
fi
|
|
183
|
+
|
|
100
184
|
# ---------- codex uninstall path ----------
|
|
101
185
|
|
|
102
186
|
if [ "$target" = "codex" ]; then
|