@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 CHANGED
@@ -1,12 +1,13 @@
1
1
  # @workweave/router
2
2
 
3
- One command, anywhere, to point Claude Code or Codex at the Weave Router.
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 or Codex, then scope
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 --scope project # per-repo install, commit settings.json (or .codex/)
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
- Two install targets:
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. Not required
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 -r --argjson p "$prices" --arg requested "$requested_norm" '
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 Codex.
3
+ # Weave Router installer for Claude Code, Codex, and opencode.
4
4
  #
5
- # Configures Claude Code (default) or the OpenAI Codex CLI (`--codex`) to
6
- # permanently route through the Weave Router. For Claude Code this writes
7
- # the router base URL, router auth header, and a status line into Claude
8
- # Code's settings.json. For Codex it writes a `model_providers.weave`
9
- # entry plus `model_provider = "weave"` into ~/.codex/config.toml (managed
10
- # block delimited by markers so re-install / uninstall is clean).
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 both targets):
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 or Codex)
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. Each target carries its
51
- # own credential-passthrough story in the router: Claude Code's logged-in
52
- # Anthropic key flows through unchanged, and Codex's `OPENAI_API_KEY` flows
53
- # through via the same header path. target_explicit tracks whether --claude
54
- # or --codex was passed so an interactive run can prompt for the choice.
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) target_label="Codex installer" ;;
101
- *) target_label="Claude Code installer" ;;
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 pipelines that
580
- # want to skip the interactive picker without depending on the default.
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 "Choose %s[1/2]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
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 patching uses jq to deep-merge env keys.
682
- if [ "$target" = "claude" ]; then
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
- else
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 -r --argjson p "$prices" --arg requested "$requested_norm" '
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "One-command installer that points Claude Code at the Weave Router.",
5
5
  "bin": {
6
6
  "weave-router": "bin.js"
package/uninstall.sh CHANGED
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # Weave Router uninstaller for Claude Code and Codex.
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 --claude.
57
- # Lets `./install.sh --uninstall --claude` (which forwards remaining
58
- # args here) succeed instead of hitting the unknown-flag catch-all.
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 Claude Code uninstall path."
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