@workweave/router 0.1.4 → 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
@@ -145,6 +145,7 @@ prices='{
145
145
  "gemini-3-pro-preview": 0.002,
146
146
  "gemini-3.1-flash-lite-preview": 0.0001,
147
147
  "gemini-3.1-pro-preview": 0.002,
148
+ "gemini-3.5-flash": 0.0015,
148
149
  "gpt-4.1": 0.002,
149
150
  "gpt-4.1-mini": 0.0004,
150
151
  "gpt-4.1-nano": 0.0001,
@@ -163,11 +164,15 @@ prices='{
163
164
  "gpt-5.5-nano": 0.00015,
164
165
  "gpt-5.5-pro": 0.03,
165
166
  "minimax/minimax-m2.7": 0.0003,
167
+ "mistralai/mistral-small-2603": 0.0002,
166
168
  "moonshotai/kimi-k2.5": 0.0006,
167
169
  "moonshotai/kimi-k2.6": 0.00095,
168
170
  "qwen/qwen3-235b-a22b-2507": 0.000071,
171
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
172
+ "qwen/qwen3-coder": 0.0009,
169
173
  "qwen/qwen3-coder-next": 0.0005,
170
174
  "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
175
+ "qwen/qwen3.5-flash-02-23": 0.00005,
171
176
  "qwen/qwen3.6-35b-a3b": 0.00015,
172
177
  "xiaomi/mimo-v2.5": 0.0004,
173
178
  "xiaomi/mimo-v2.5-pro": 0.001,
@@ -190,6 +195,7 @@ prices='{
190
195
  "gemini-3-pro-preview": 0.008,
191
196
  "gemini-3.1-flash-lite-preview": 0.0004,
192
197
  "gemini-3.1-pro-preview": 0.008,
198
+ "gemini-3.5-flash": 0.009,
193
199
  "gpt-4.1": 0.008,
194
200
  "gpt-4.1-mini": 0.0016,
195
201
  "gpt-4.1-nano": 0.0004,
@@ -208,11 +214,15 @@ prices='{
208
214
  "gpt-5.5-nano": 0.0006,
209
215
  "gpt-5.5-pro": 0.12,
210
216
  "minimax/minimax-m2.7": 0.0012,
217
+ "mistralai/mistral-small-2603": 0.0006,
211
218
  "moonshotai/kimi-k2.5": 0.003,
212
219
  "moonshotai/kimi-k2.6": 0.004,
213
220
  "qwen/qwen3-235b-a22b-2507": 0.000463,
221
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
222
+ "qwen/qwen3-coder": 0.0027,
214
223
  "qwen/qwen3-coder-next": 0.0012,
215
224
  "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
225
+ "qwen/qwen3.5-flash-02-23": 0.00015,
216
226
  "qwen/qwen3.6-35b-a3b": 0.00095,
217
227
  "xiaomi/mimo-v2.5": 0.002,
218
228
  "xiaomi/mimo-v2.5-pro": 0.003,
@@ -272,9 +282,24 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
272
282
  # The marker regex tolerates the optional "(<provider>)" segment and a
273
283
  # `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
274
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.
275
298
  read -r session_savings tot_in tot_out tot_cache_read tot_cache_write < <(
276
- jq -r --argjson p "$prices" --arg requested "$requested_norm" '
277
- 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
+ .[] |
278
303
  .message as $m |
279
304
  ($m.model // "" | sub("\\[[^]]*\\]$"; "") | sub("-[0-9]{8}$"; "")) as $rm |
280
305
  {
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: Pin this session to a specific model via the Weave Router
3
+ argument-hint: <model-id>
4
+ ---
5
+
6
+ /force-model $ARGUMENTS
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Clear an active /force-model pin and resume automatic routing
3
+ ---
4
+
5
+ /unforce-model
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"
@@ -395,6 +406,10 @@ write_codex_config() {
395
406
  if [ -n "$block_name" ]; then
396
407
  headers_parts="${headers_parts}, \"X-Weave-User-Name\" = \"${esc_name}\""
397
408
  fi
409
+ # Tag the client so telemetry can attribute traffic to Codex vs other CLIs
410
+ # that share the same router key. The router otherwise has to guess from
411
+ # User-Agent.
412
+ headers_parts="${headers_parts}, \"X-App\" = \"codex\""
398
413
  local headers_line="http_headers = { ${headers_parts} }"
399
414
 
400
415
  local block
@@ -469,6 +484,92 @@ TOML
469
484
  chmod 600 "$config_file"
470
485
  }
471
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
+
472
573
  # resolve_user_name mirrors resolve_user_email but for display name. Priority:
473
574
  # WEAVE_USER_NAME env override → git config user.name → empty. We don't
474
575
  # prompt for name independently: if email prompting yielded nothing, name
@@ -571,9 +672,13 @@ while [ $# -gt 0 ]; do
571
672
  --codex)
572
673
  target="codex"; target_explicit="true"; shift
573
674
  ;;
675
+ --opencode)
676
+ target="opencode"; target_explicit="true"; shift
677
+ ;;
574
678
  --claude)
575
- # No-op selector for symmetry with --codex. Useful in pipelines that
576
- # 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.
577
682
  target="claude"; target_explicit="true"; shift
578
683
  ;;
579
684
  -h|--help)
@@ -603,11 +708,13 @@ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r
603
708
  printf "%sInstall target:%s\n" "$C_BOLD" "$C_RESET"
604
709
  printf " %s1)%s Claude Code %s— patches ~/.claude/settings.json (or <repo>/.claude/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
605
710
  printf " %s2)%s Codex %s— patches ~/.codex/config.toml (or <repo>/.codex/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
606
- 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"
607
713
  read -r target_choice </dev/tty || target_choice=""
608
714
  case "${target_choice:-1}" in
609
715
  1|""|claude|c|C) target="claude" ;;
610
716
  2|codex|x|X) target="codex" ;;
717
+ 3|opencode|o|O) target="opencode" ;;
611
718
  *) err "invalid choice: $target_choice"; exit 2 ;;
612
719
  esac
613
720
  fi
@@ -631,6 +738,19 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
631
738
  scope_project_path="<repo>/.codex/"
632
739
  scope_cli_label="codex"
633
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
+ ;;
634
754
  *)
635
755
  scope_user_path="~/.claude/"
636
756
  scope_project_path="<repo>/.claude/"
@@ -674,8 +794,9 @@ fi
674
794
  [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
675
795
 
676
796
  # Codex install only writes a TOML file (managed via awk) so jq isn't needed.
677
- # Claude Code's settings.json patching uses jq to deep-merge env keys.
678
- 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
679
800
  require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
680
801
  fi
681
802
  require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
@@ -693,6 +814,12 @@ case "$target" in
693
814
  warn "Continuing — config.toml will be written and will take effect once Codex is installed."
694
815
  fi
695
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
+ ;;
696
823
  esac
697
824
 
698
825
  script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
@@ -763,7 +890,7 @@ if [ "$target" = "claude" ]; then
763
890
  fi
764
891
 
765
892
  mkdir -p "$settings_dir" "$statusline_dir"
766
- else
893
+ elif [ "$target" = "codex" ]; then
767
894
  # Codex CLI reads config from ~/.codex/config.toml by default. For project
768
895
  # scope we write to <repo>/.codex/config.toml; the user invokes Codex with
769
896
  # `CODEX_HOME=<repo>/.codex codex` (or runs from the repo if Codex auto-
@@ -778,6 +905,34 @@ else
778
905
  fi
779
906
 
780
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"
781
936
  fi
782
937
 
783
938
  # ---------- token handling ----------
@@ -839,11 +994,57 @@ else
839
994
  info "No identity set — router traffic will be attributed by account UUID only."
840
995
  fi
841
996
 
997
+ # ---------- slash command wrappers (shared by both targets) ----------
998
+ #
999
+ # Claude Code intercepts any prompt starting with "/" as a local slash command,
1000
+ # so a typed /force-model would resolve to "Unknown command" and never reach
1001
+ # the router. Codex CLI does the same (its built-in / menu has its own set).
1002
+ # Drop wrapper markdown files into the per-target commands directory so the
1003
+ # slash invocation expands locally into a literal "/force-model …" prompt that
1004
+ # the router's first-line parser picks up.
1005
+ #
1006
+ # Layout:
1007
+ # Claude: <settings_dir>/commands/{force-model,unforce-model}.md → /force-model
1008
+ # Codex: <codex_dir>/prompts/{force-model,unforce-model}.md → /prompts:force-model
1009
+ #
1010
+ # Files come from install/commands/ in the repo (or the colocated commands/
1011
+ # directory the npm package ships alongside install.sh).
1012
+ install_slash_commands() {
1013
+ dst_dir="$1"
1014
+ commands_src_dir=""
1015
+ for candidate in \
1016
+ "$script_dir/commands" \
1017
+ "$script_dir/../commands"
1018
+ do
1019
+ if [ -d "$candidate" ]; then
1020
+ commands_src_dir="$candidate"
1021
+ break
1022
+ fi
1023
+ done
1024
+ [ -n "$commands_src_dir" ] || return 0
1025
+
1026
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1027
+ refuse_if_symlink "$dst_dir"
1028
+ fi
1029
+ mkdir -p "$dst_dir"
1030
+ for cmd in force-model unforce-model; do
1031
+ src="$commands_src_dir/$cmd.md"
1032
+ dst="$dst_dir/$cmd.md"
1033
+ [ -f "$src" ] || continue
1034
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
1035
+ refuse_if_symlink "$dst"
1036
+ fi
1037
+ cp "$src" "$dst"
1038
+ done
1039
+ ok "Slash commands written to $dst_dir (force-model, unforce-model)"
1040
+ }
1041
+
842
1042
  # ---------- codex install path (dispatch + exit before the Claude-only writes) ----------
843
1043
 
844
1044
  if [ "$target" = "codex" ]; then
845
1045
  write_codex_config "$codex_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
846
1046
  ok "Codex config written to $codex_config_file"
1047
+ install_slash_commands "$codex_dir/prompts"
847
1048
 
848
1049
  # Project scope: ensure the per-teammate config (which holds the router key)
849
1050
  # is gitignored. The base URL is the same for every teammate, so a
@@ -893,6 +1094,56 @@ if [ "$target" = "codex" ]; then
893
1094
  exit 0
894
1095
  fi
895
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
+
896
1147
  # ---------- write the statusline script ----------
897
1148
 
898
1149
  cat > "$statusline_file" << 'STATUSLINE_EOF'
@@ -1043,6 +1294,7 @@ prices='{
1043
1294
  "gemini-3-pro-preview": 0.002,
1044
1295
  "gemini-3.1-flash-lite-preview": 0.0001,
1045
1296
  "gemini-3.1-pro-preview": 0.002,
1297
+ "gemini-3.5-flash": 0.0015,
1046
1298
  "gpt-4.1": 0.002,
1047
1299
  "gpt-4.1-mini": 0.0004,
1048
1300
  "gpt-4.1-nano": 0.0001,
@@ -1061,11 +1313,15 @@ prices='{
1061
1313
  "gpt-5.5-nano": 0.00015,
1062
1314
  "gpt-5.5-pro": 0.03,
1063
1315
  "minimax/minimax-m2.7": 0.0003,
1316
+ "mistralai/mistral-small-2603": 0.0002,
1064
1317
  "moonshotai/kimi-k2.5": 0.0006,
1065
1318
  "moonshotai/kimi-k2.6": 0.00095,
1066
1319
  "qwen/qwen3-235b-a22b-2507": 0.000071,
1320
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
1321
+ "qwen/qwen3-coder": 0.0009,
1067
1322
  "qwen/qwen3-coder-next": 0.0005,
1068
1323
  "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
1324
+ "qwen/qwen3.5-flash-02-23": 0.00005,
1069
1325
  "qwen/qwen3.6-35b-a3b": 0.00015,
1070
1326
  "xiaomi/mimo-v2.5": 0.0004,
1071
1327
  "xiaomi/mimo-v2.5-pro": 0.001,
@@ -1088,6 +1344,7 @@ prices='{
1088
1344
  "gemini-3-pro-preview": 0.008,
1089
1345
  "gemini-3.1-flash-lite-preview": 0.0004,
1090
1346
  "gemini-3.1-pro-preview": 0.008,
1347
+ "gemini-3.5-flash": 0.009,
1091
1348
  "gpt-4.1": 0.008,
1092
1349
  "gpt-4.1-mini": 0.0016,
1093
1350
  "gpt-4.1-nano": 0.0004,
@@ -1106,11 +1363,15 @@ prices='{
1106
1363
  "gpt-5.5-nano": 0.0006,
1107
1364
  "gpt-5.5-pro": 0.12,
1108
1365
  "minimax/minimax-m2.7": 0.0012,
1366
+ "mistralai/mistral-small-2603": 0.0006,
1109
1367
  "moonshotai/kimi-k2.5": 0.003,
1110
1368
  "moonshotai/kimi-k2.6": 0.004,
1111
1369
  "qwen/qwen3-235b-a22b-2507": 0.000463,
1370
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
1371
+ "qwen/qwen3-coder": 0.0027,
1112
1372
  "qwen/qwen3-coder-next": 0.0012,
1113
1373
  "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
1374
+ "qwen/qwen3.5-flash-02-23": 0.00015,
1114
1375
  "qwen/qwen3.6-35b-a3b": 0.00095,
1115
1376
  "xiaomi/mimo-v2.5": 0.002,
1116
1377
  "xiaomi/mimo-v2.5-pro": 0.003,
@@ -1170,9 +1431,24 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
1170
1431
  # The marker regex tolerates the optional "(<provider>)" segment and a
1171
1432
  # `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
1172
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.
1173
1447
  read -r session_savings tot_in tot_out tot_cache_read tot_cache_write < <(
1174
- jq -r --argjson p "$prices" --arg requested "$requested_norm" '
1175
- 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
+ .[] |
1176
1452
  .message as $m |
1177
1453
  ($m.model // "" | sub("\\[[^]]*\\]$"; "") | sub("-[0-9]{8}$"; "")) as $rm |
1178
1454
  {
@@ -1297,6 +1573,7 @@ fi
1297
1573
  if [ -n "$user_name" ]; then
1298
1574
  custom_headers="$custom_headers"$'\n'"X-Weave-User-Name: $user_name"
1299
1575
  fi
1576
+ custom_headers="$custom_headers"$'\n'"X-App: claude-code"
1300
1577
 
1301
1578
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1302
1579
  jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
@@ -1329,6 +1606,9 @@ else
1329
1606
  fi
1330
1607
  ok "Settings written to $settings_file"
1331
1608
 
1609
+ # Slash command wrappers — see install_slash_commands() below for the why.
1610
+ install_slash_commands "$settings_dir/commands"
1611
+
1332
1612
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1333
1613
  jq -n --arg header "$custom_headers" '{
1334
1614
  env: { ANTHROPIC_CUSTOM_HEADERS: $header }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.1.4",
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"
@@ -10,6 +10,7 @@
10
10
  "install.sh",
11
11
  "uninstall.sh",
12
12
  "cc-statusline.sh",
13
+ "commands/",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],
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
@@ -156,6 +240,22 @@ if [ "$target" = "codex" ]; then
156
240
  info "No Codex config at $codex_config_file (already uninstalled?)"
157
241
  fi
158
242
 
243
+ # Remove only the prompt files this installer owns; leave any user-authored
244
+ # entries in prompts/ alone. The dir itself is dropped only if empty after.
245
+ codex_prompts_dir="$codex_dir/prompts"
246
+ if [ -d "$codex_prompts_dir" ]; then
247
+ refuse_if_symlink "$codex_prompts_dir"
248
+ for cmd in force-model unforce-model; do
249
+ cmd_file="$codex_prompts_dir/$cmd.md"
250
+ if [ -f "$cmd_file" ]; then
251
+ refuse_if_symlink "$cmd_file"
252
+ rm -f "$cmd_file"
253
+ ok "Removed $cmd_file"
254
+ fi
255
+ done
256
+ rmdir "$codex_prompts_dir" 2>/dev/null || true
257
+ fi
258
+
159
259
  if [ -n "$install_dir" ]; then
160
260
  ok "Weave Router uninstalled from $install_dir (Codex)."
161
261
  else
@@ -269,6 +369,24 @@ for f in "$statusline_file"; do
269
369
  fi
270
370
  done
271
371
 
372
+ # Remove only the slash command files this installer owns; leave any other
373
+ # files in commands/ alone. The directory itself stays if it still contains
374
+ # unrelated user commands.
375
+ commands_dir="$(dirname "$settings_file")/commands"
376
+ if [ -d "$commands_dir" ]; then
377
+ refuse_if_symlink "$commands_dir"
378
+ for cmd in force-model unforce-model; do
379
+ cmd_file="$commands_dir/$cmd.md"
380
+ if [ -f "$cmd_file" ]; then
381
+ refuse_if_symlink "$cmd_file"
382
+ rm -f "$cmd_file"
383
+ ok "Removed $cmd_file"
384
+ fi
385
+ done
386
+ # Clean up the dir only if we left nothing behind.
387
+ rmdir "$commands_dir" 2>/dev/null || true
388
+ fi
389
+
272
390
  if [ -n "$install_dir" ]; then
273
391
  ok "Weave Router uninstalled from $install_dir."
274
392
  else