@workweave/router 0.1.5 → 0.1.7

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)
@@ -18,11 +19,27 @@ Version-pin for reproducible setups:
18
19
  npx @workweave/router@0.1.0 --claude --scope project
19
20
  ```
20
21
 
22
+ Switch on/off without uninstalling (keeps your config so switching back is
23
+ instant; requires an explicit client):
24
+
25
+ ```bash
26
+ npx @workweave/router off --claude # route Claude Code directly to Anthropic
27
+ npx @workweave/router on --claude # route Claude Code through the router again
28
+ npx @workweave/router status --codex # is Codex on the router or direct?
29
+ ```
30
+
31
+ Claude Code reads its router setting at launch, so quit and reopen it after an
32
+ on/off. Codex and opencode pick it up on their next run. Inside Claude Code the
33
+ slash commands `/router-off`, `/router-on`, and `/router-status` do the same.
34
+ Cursor has no config file we own — toggle its base URL override in **Settings →
35
+ Models** instead.
36
+
21
37
  Uninstall:
22
38
 
23
39
  ```bash
24
40
  npx @workweave/router --uninstall # Claude Code, user scope
25
41
  npx @workweave/router --uninstall --codex # Codex, user scope
42
+ npx @workweave/router --uninstall --opencode # opencode, user scope
26
43
  npx @workweave/router --uninstall --scope project # Claude Code, inside the repo
27
44
  npx @workweave/router --uninstall --codex --scope project
28
45
  ```
@@ -35,7 +52,7 @@ Node ≥ 18 — no `curl | sh`, no Git clone, no PATH fiddling. Everything the
35
52
  shell installer documents (targets, scopes, flags, environment variables)
36
53
  works identically here.
37
54
 
38
- Two install targets:
55
+ Three install targets:
39
56
 
40
57
  - **Claude Code** (default) — patches `~/.claude/settings.json` (or
41
58
  `<repo>/.claude/settings.json` with `--scope project`) so `claude` routes
@@ -47,6 +64,13 @@ Two install targets:
47
64
  through to api.openai.com. The block lives between begin/end markers so
48
65
  re-running the installer rewrites it cleanly and `--uninstall --codex`
49
66
  removes it without touching the rest of your config.
67
+ - **opencode** (`--opencode`) — merges a `provider.weave` entry (backed by
68
+ opencode's built-in `@ai-sdk/anthropic` provider) into
69
+ `~/.config/opencode/opencode.json` (or `<repo>/opencode.json` with
70
+ `--scope project`). The router speaks the Anthropic Messages API
71
+ natively, so opencode talks to it unmodified. Re-install rewrites only
72
+ the managed `provider.weave` block; `--uninstall --opencode` strips it
73
+ and leaves your other providers and settings alone.
50
74
 
51
75
  See the [main installer docs](https://github.com/workweave/router/tree/main/install)
52
76
  for the full reference.
@@ -55,8 +79,8 @@ for the full reference.
55
79
 
56
80
  - Node ≥ 18 (ships with `npx`)
57
81
  - `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.
82
+ - `jq` on PATH — used by the Claude Code status line script and the
83
+ opencode JSON merge. Not required for the Codex path.
60
84
 
61
85
  ## Why npx
62
86
 
package/cc-statusline.sh CHANGED
@@ -130,8 +130,9 @@ normalize_model() {
130
130
  prices='{
131
131
  "input": {
132
132
  "claude-haiku-4-5": 0.0008,
133
- "claude-opus-4-6": 0.015,
134
- "claude-opus-4-7": 0.015,
133
+ "claude-opus-4-6": 0.005,
134
+ "claude-opus-4-7": 0.005,
135
+ "claude-opus-4-8": 0.005,
135
136
  "claude-sonnet-4-5": 0.003,
136
137
  "claude-sonnet-4-6": 0.003,
137
138
  "deepseek/deepseek-v4-flash": 0.00014,
@@ -164,20 +165,25 @@ prices='{
164
165
  "gpt-5.5-nano": 0.00015,
165
166
  "gpt-5.5-pro": 0.03,
166
167
  "minimax/minimax-m2.7": 0.0003,
168
+ "mistralai/mistral-small-2603": 0.0002,
167
169
  "moonshotai/kimi-k2.5": 0.0006,
168
170
  "moonshotai/kimi-k2.6": 0.00095,
169
- "qwen/qwen3-235b-a22b-2507": 0.000071,
171
+ "qwen/qwen3-235b-a22b-2507": 0.0002266,
172
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
173
+ "qwen/qwen3-coder": 0.0009,
170
174
  "qwen/qwen3-coder-next": 0.0005,
171
175
  "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
176
+ "qwen/qwen3.5-flash-02-23": 0.00005,
172
177
  "qwen/qwen3.6-35b-a3b": 0.00015,
173
- "xiaomi/mimo-v2.5": 0.0004,
174
178
  "xiaomi/mimo-v2.5-pro": 0.001,
175
- "z-ai/glm-5": 0.0006
179
+ "z-ai/glm-5": 0.0006,
180
+ "z-ai/glm-5.1": 0.00105
176
181
  },
177
182
  "output": {
178
183
  "claude-haiku-4-5": 0.004,
179
- "claude-opus-4-6": 0.075,
180
- "claude-opus-4-7": 0.075,
184
+ "claude-opus-4-6": 0.025,
185
+ "claude-opus-4-7": 0.025,
186
+ "claude-opus-4-8": 0.025,
181
187
  "claude-sonnet-4-5": 0.015,
182
188
  "claude-sonnet-4-6": 0.015,
183
189
  "deepseek/deepseek-v4-flash": 0.00028,
@@ -210,15 +216,19 @@ prices='{
210
216
  "gpt-5.5-nano": 0.0006,
211
217
  "gpt-5.5-pro": 0.12,
212
218
  "minimax/minimax-m2.7": 0.0012,
219
+ "mistralai/mistral-small-2603": 0.0006,
213
220
  "moonshotai/kimi-k2.5": 0.003,
214
221
  "moonshotai/kimi-k2.6": 0.004,
215
- "qwen/qwen3-235b-a22b-2507": 0.000463,
222
+ "qwen/qwen3-235b-a22b-2507": 0.0009064,
223
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
224
+ "qwen/qwen3-coder": 0.0027,
216
225
  "qwen/qwen3-coder-next": 0.0012,
217
226
  "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
227
+ "qwen/qwen3.5-flash-02-23": 0.00015,
218
228
  "qwen/qwen3.6-35b-a3b": 0.00095,
219
- "xiaomi/mimo-v2.5": 0.002,
220
229
  "xiaomi/mimo-v2.5-pro": 0.003,
221
- "z-ai/glm-5": 0.00208
230
+ "z-ai/glm-5": 0.00208,
231
+ "z-ai/glm-5.1": 0.0035
222
232
  }
223
233
  }'
224
234
  # END_GENERATED_PRICES
@@ -274,9 +284,24 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
274
284
  # The marker regex tolerates the optional "(<provider>)" segment and a
275
285
  # `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
276
286
  # against context-tiered or dated model ids still parse cleanly.
287
+ #
288
+ # Dedup note: CC writes one JSONL entry per *content block* in an
289
+ # assistant turn (text, text, tool_use → 3 entries), and every entry
290
+ # carries the same `message.usage`. Summing per-entry triple-counts the
291
+ # turn. We dedupe on (message.id, message.usage) before summing:
292
+ # * For native Anthropic upstreams message.id is unique per turn, so
293
+ # this collapses the content-block fan-out cleanly.
294
+ # * For non-Anthropic upstreams that round-trip through the router's
295
+ # translator, message.id can be a constant placeholder
296
+ # ("msg_translated"); usage still differs per turn (input_tokens
297
+ # grows), so the composite key keeps turns distinct. Two turns with
298
+ # byte-identical id AND usage would still collapse, but that's a
299
+ # genuine retry/duplicate we want to drop.
277
300
  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") |
301
+ jq -rs --argjson p "$prices" --arg requested "$requested_norm" '
302
+ [.[] | select(.type=="assistant")] |
303
+ unique_by([.message.id, .message.usage]) |
304
+ .[] |
280
305
  .message as $m |
281
306
  ($m.model // "" | sub("\\[[^]]*\\]$"; "") | sub("-[0-9]{8}$"; "")) as $rm |
282
307
  {
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Route Claude Code directly to Anthropic again (turn the Weave Router off)
3
+ allowed-tools: Bash(npx:*)
4
+ ---
5
+
6
+ Turn the Weave Router **off** for Claude Code by running:
7
+
8
+ `npx @workweave/router off --claude{{SCOPE}}`
9
+
10
+ Then tell me to fully quit and reopen Claude Code — it only reads the router setting at startup, so the change won't apply to the current session until it restarts.
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Route Claude Code through the Weave Router again (turn it back on)
3
+ allowed-tools: Bash(npx:*)
4
+ ---
5
+
6
+ Turn the Weave Router **on** for Claude Code by running:
7
+
8
+ `npx @workweave/router on --claude{{SCOPE}}`
9
+
10
+ Then tell me to fully quit and reopen Claude Code so the change takes effect — it only reads the router setting at startup.
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Show whether Claude Code is routing through the Weave Router or directly to Anthropic
3
+ allowed-tools: Bash(npx:*)
4
+ ---
5
+
6
+ Report whether Claude Code is currently using the Weave Router by running:
7
+
8
+ `npx @workweave/router status --claude{{SCOPE}}`
9
+
10
+ Then summarize the result for me in one line.
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
@@ -30,6 +37,18 @@
30
37
  # npx @workweave/router --non-interactive # require WEAVE_ROUTER_KEY env var (defaults target to claude)
31
38
  # npx @workweave/router --quiet # suppress banner, ping check, and trailing tips
32
39
  # npx @workweave/router --uninstall # remove a previous install (delegates to uninstall.sh)
40
+ #
41
+ # Toggle an existing install on/off without losing the router config (so
42
+ # switching back is instant). These never prompt for a key and require an
43
+ # explicit client (--claude / --codex / --opencode); they only flip config
44
+ # that install.sh already wrote:
45
+ # npx @workweave/router off --claude # route directly to Anthropic again (Claude Code)
46
+ # npx @workweave/router on --codex # route through the Weave Router again (Codex)
47
+ # npx @workweave/router status --opencode # report whether opencode is on the router or direct
48
+ # Claude Code reads env at launch, so an off/on takes effect on the next
49
+ # `claude` start; Codex and opencode re-read config every invocation.
50
+ # Cursor's base URL lives in its own settings UI (no file we own), so there's
51
+ # nothing to toggle here — flip "Override OpenAI Base URL" in Cursor settings.
33
52
 
34
53
  set -euo pipefail
35
54
 
@@ -47,14 +66,22 @@ non_interactive="false"
47
66
  quiet="false"
48
67
  router_key_header="X-Weave-Router-Key"
49
68
  # 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.
69
+ # settings.json; "codex" writes ~/.codex/config.toml; "opencode" merges a
70
+ # provider block into opencode.json. Each target carries its own
71
+ # credential-passthrough story in the router: Claude Code's logged-in
72
+ # Anthropic key flows through unchanged, Codex's `OPENAI_API_KEY` flows
73
+ # through via the same header path, and opencode talks to the router via
74
+ # its anthropic-compatible API surface. target_explicit tracks whether
75
+ # --claude / --codex / --opencode was passed so an interactive run can
76
+ # prompt for the choice.
55
77
  target="claude"
56
78
  target_explicit="false"
57
79
 
80
+ # Operation mode. "install" (default) writes/refreshes config. "off"/"on"/
81
+ # "status" toggle or report an existing install without touching the router
82
+ # key/identity — see the toggle_* helpers and the dispatch block below.
83
+ mode="install"
84
+
58
85
  # ---------- helpers ----------
59
86
 
60
87
  # Detect whether stdout is a real terminal that grokks ANSI escapes. Pipes,
@@ -88,6 +115,29 @@ info() { printf "%s==>%s %s\n" "$C_CYAN" "$C_RESET" "$*"; }
88
115
  ok() { printf "%s✓%s %s\n" "$C_GREEN" "$C_RESET" "$*"; }
89
116
  skip() { printf "%s⊙%s %s%s%s\n" "$C_DIM" "$C_RESET" "$C_DIM" "$*" "$C_RESET"; }
90
117
 
118
+ # uninstall_cmd echoes the npx one-liner that reverses the current install,
119
+ # matching the target (--codex/--opencode), scope, and --dir so the hint we
120
+ # print after a successful install is copy-paste-correct. Kept in sync with
121
+ # uninstall.sh's flag surface.
122
+ uninstall_cmd() {
123
+ local cmd="npx -y @workweave/router --uninstall"
124
+ case "$target" in
125
+ codex) cmd="$cmd --codex" ;;
126
+ opencode) cmd="$cmd --opencode" ;;
127
+ esac
128
+ [ "$scope" = "project" ] && cmd="$cmd --scope project"
129
+ [ -n "$install_dir" ] && cmd="$cmd --dir $(printf '%q' "$install_dir")"
130
+ printf '%s' "$cmd"
131
+ }
132
+
133
+ # print_uninstall_hint prints the reverse command on its own labeled line so
134
+ # every successful install ends by telling the user exactly how to undo it.
135
+ print_uninstall_hint() {
136
+ [ "$quiet" = "true" ] && return 0
137
+ printf "%sTo uninstall:%s %s%s%s\n" \
138
+ "$C_BOLD" "$C_RESET" "$C_CYAN" "$(uninstall_cmd)" "$C_RESET"
139
+ }
140
+
91
141
  # ---------- banner ----------
92
142
  #
93
143
  # Print the WEAVE wordmark in brand orange. Skipped under --quiet or when
@@ -97,8 +147,9 @@ print_banner() {
97
147
  [ "$tty_out" = "true" ] || return 0
98
148
  local target_label
99
149
  case "$target" in
100
- codex) target_label="Codex installer" ;;
101
- *) target_label="Claude Code installer" ;;
150
+ codex) target_label="Codex installer" ;;
151
+ opencode) target_label="opencode installer" ;;
152
+ *) target_label="Claude Code installer" ;;
102
153
  esac
103
154
  printf '\n'
104
155
  printf '%s ╦ ╦╔═╗╔═╗╦ ╦╔═╗%s\n' "$C_BRAND" "$C_RESET"
@@ -473,6 +524,93 @@ TOML
473
524
  chmod 600 "$config_file"
474
525
  }
475
526
 
527
+ # write_opencode_config merges a managed `provider.weave` entry into opencode's
528
+ # opencode.json (anthropic-compatible — the router speaks the Anthropic
529
+ # Messages API natively, so opencode's bundled @ai-sdk/anthropic provider
530
+ # works unmodified). Re-running rewrites the block in-place via jq; uninstall
531
+ # strips it the same way. We also set `model` at the top level so a fresh
532
+ # `opencode` invocation picks the router by default; if the user has set
533
+ # their own model already, we leave it alone.
534
+ #
535
+ # Usage: write_opencode_config <config_file_path> <base_url> <api_key> [user_email] [user_name]
536
+ write_opencode_config() {
537
+ local config_file="$1"
538
+ local block_url="$2"
539
+ local block_key="$3"
540
+ local block_email="${4:-}"
541
+ local block_name="${5:-}"
542
+
543
+ # Build the headers object piecewise so empty email/name vanish from the
544
+ # final JSON. opencode forwards the `headers` map verbatim to the upstream
545
+ # provider, so the router sees the same X-Weave-* triplet here that it
546
+ # would from Claude Code or Codex. The X-App tag lets router telemetry
547
+ # attribute traffic to opencode specifically.
548
+ local headers_json
549
+ headers_json="$(jq -n \
550
+ --arg key "$block_key" \
551
+ --arg email "$block_email" \
552
+ --arg name "$block_name" '
553
+ {"X-Weave-Router-Key": $key, "X-App": "opencode"}
554
+ | (if $email != "" then . + {"X-Weave-User-Email": $email} else . end)
555
+ | (if $name != "" then . + {"X-Weave-User-Name": $name } else . end)
556
+ ')"
557
+
558
+ # Headline models we surface in opencode's picker. The router re-routes
559
+ # each request anyway, so this list is mostly UX — what shows up when the
560
+ # user runs /models inside opencode. Keep it short and Anthropic-shaped
561
+ # so the bundled @ai-sdk/anthropic provider can request them.
562
+ #
563
+ # apiKey is set to the router key as well as planted in headers. opencode's
564
+ # @ai-sdk/anthropic provider treats apiKey as required at config-parse time
565
+ # and otherwise falls back to ANTHROPIC_API_KEY from the environment;
566
+ # without this, a user who's never had an Anthropic key in their shell hits
567
+ # a startup error before the router ever sees a request. The router itself
568
+ # ignores the value (auth runs off X-Weave-Router-Key); apiKey here is just
569
+ # a placeholder that satisfies the SDK's "is auth configured" check.
570
+ local block
571
+ block="$(jq -n \
572
+ --arg url "$block_url/v1" \
573
+ --arg key "$block_key" \
574
+ --argjson headers "$headers_json" '
575
+ {
576
+ npm: "@ai-sdk/anthropic",
577
+ name: "Weave Router",
578
+ options: { apiKey: $key, baseURL: $url, headers: $headers },
579
+ models: {
580
+ "claude-opus-4-8": { name: "Claude Opus 4.8 (via Weave Router)" },
581
+ "claude-opus-4-7": { name: "Claude Opus 4.7 (via Weave Router)" },
582
+ "claude-sonnet-4-6": { name: "Claude Sonnet 4.6 (via Weave Router)" },
583
+ "claude-haiku-4-5": { name: "Claude Haiku 4.5 (via Weave Router)" }
584
+ }
585
+ }
586
+ ')"
587
+
588
+ # Merge into any existing opencode.json. We always overwrite provider.weave
589
+ # so re-install reflects the latest key/identity, but we leave the rest of
590
+ # the file (other providers, mcp, agent settings) untouched. Top-level
591
+ # `model` is only set when the user hasn't already picked one.
592
+ local merged
593
+ if [ -f "$config_file" ]; then
594
+ merged="$(jq --argjson block "$block" '
595
+ .provider = ((.provider // {}) | .weave = $block)
596
+ | (if (.model // "") == "" then .model = "weave/claude-sonnet-4-6" else . end)
597
+ | (.["$schema"] //= "https://opencode.ai/config.json")
598
+ ' "$config_file")"
599
+ else
600
+ merged="$(jq -n --argjson block "$block" '
601
+ {
602
+ "$schema": "https://opencode.ai/config.json",
603
+ model: "weave/claude-sonnet-4-6",
604
+ provider: { weave: $block }
605
+ }
606
+ ')"
607
+ fi
608
+ printf '%s\n' "$merged" >"$config_file"
609
+ # 0600: the file holds a router key. Even at user scope, mode 644 would
610
+ # leak the key to any local user on a shared box.
611
+ chmod 600 "$config_file"
612
+ }
613
+
476
614
  # resolve_user_name mirrors resolve_user_email but for display name. Priority:
477
615
  # WEAVE_USER_NAME env override → git config user.name → empty. We don't
478
616
  # prompt for name independently: if email prompting yielded nothing, name
@@ -575,11 +713,20 @@ while [ $# -gt 0 ]; do
575
713
  --codex)
576
714
  target="codex"; target_explicit="true"; shift
577
715
  ;;
716
+ --opencode)
717
+ target="opencode"; target_explicit="true"; shift
718
+ ;;
578
719
  --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.
720
+ # No-op selector for symmetry with --codex / --opencode. Useful in
721
+ # pipelines that want to skip the interactive picker without depending
722
+ # on the default.
581
723
  target="claude"; target_explicit="true"; shift
582
724
  ;;
725
+ off|--off|on|--on|status|--status)
726
+ # Toggle/report verbs. Bare (off) or dashed (--off) both accepted; the
727
+ # npm wrapper forwards argv verbatim so either form reaches us.
728
+ mode="${1#--}"; shift
729
+ ;;
583
730
  -h|--help)
584
731
  usage 0
585
732
  ;;
@@ -589,6 +736,17 @@ while [ $# -gt 0 ]; do
589
736
  esac
590
737
  done
591
738
 
739
+ # Toggle verbs only flip config install.sh already wrote: no key, no identity,
740
+ # no prompts. Require an explicit client so we never guess which config to
741
+ # touch, and suppress every interactive prompt downstream.
742
+ if [ "$mode" != "install" ]; then
743
+ non_interactive="true"
744
+ if [ "$target_explicit" != "true" ]; then
745
+ err "'$mode' requires an explicit client: --claude, --codex, or --opencode"
746
+ exit 2
747
+ fi
748
+ fi
749
+
592
750
  if [ -z "$base_url" ]; then
593
751
  base_url="$DEFAULT_BASE_URL"
594
752
  fi
@@ -607,11 +765,13 @@ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r
607
765
  printf "%sInstall target:%s\n" "$C_BOLD" "$C_RESET"
608
766
  printf " %s1)%s Claude Code %s— patches ~/.claude/settings.json (or <repo>/.claude/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
609
767
  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"
768
+ printf " %s3)%s opencode %s patches ~/.config/opencode/opencode.json (or <repo>/opencode.json)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
769
+ printf "Choose %s[1/2/3]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
611
770
  read -r target_choice </dev/tty || target_choice=""
612
771
  case "${target_choice:-1}" in
613
772
  1|""|claude|c|C) target="claude" ;;
614
773
  2|codex|x|X) target="codex" ;;
774
+ 3|opencode|o|O) target="opencode" ;;
615
775
  *) err "invalid choice: $target_choice"; exit 2 ;;
616
776
  esac
617
777
  fi
@@ -620,7 +780,8 @@ fi
620
780
  # users see when `make full-setup` hands off to install.sh is the wordmark,
621
781
  # not a bare "Install scope:" line. Target prompt above already finalized
622
782
  # $target, so the banner's per-target label reflects the user's choice.
623
- print_banner
783
+ # Toggle verbs stay terse — skip the banner so `status` prints one clean line.
784
+ [ "$mode" = "install" ] && print_banner
624
785
 
625
786
  # ---------- interactive scope prompt ----------
626
787
 
@@ -635,6 +796,19 @@ if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interacti
635
796
  scope_project_path="<repo>/.codex/"
636
797
  scope_cli_label="codex"
637
798
  ;;
799
+ opencode)
800
+ # Match the actual install path, which honors XDG_CONFIG_HOME. Showing a
801
+ # hardcoded "~/.config/opencode/" here lied to users with a custom
802
+ # $XDG_CONFIG_HOME — they'd see one path in the prompt and the installer
803
+ # would write to another.
804
+ if [ -n "${XDG_CONFIG_HOME:-}" ]; then
805
+ scope_user_path="$XDG_CONFIG_HOME/opencode/"
806
+ else
807
+ scope_user_path="~/.config/opencode/"
808
+ fi
809
+ scope_project_path="<repo>/opencode.json"
810
+ scope_cli_label="opencode"
811
+ ;;
638
812
  *)
639
813
  scope_user_path="~/.claude/"
640
814
  scope_project_path="<repo>/.claude/"
@@ -675,14 +849,22 @@ fi
675
849
 
676
850
  # ---------- pre-flight ----------
677
851
 
678
- [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
852
+ if [ "$mode" = "install" ]; then
853
+ [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
854
+ else
855
+ [ "$quiet" = "true" ] || info "mode=${C_BOLD}${mode}${C_RESET} scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET}"
856
+ fi
679
857
 
680
858
  # 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
859
+ # Claude Code's settings.json and opencode's opencode.json patching both use
860
+ # jq to deep-merge / structurally rewrite JSON. Toggling those clients reads
861
+ # and rewrites the same JSON, so jq is required there too.
862
+ if [ "$target" = "claude" ] || [ "$target" = "opencode" ]; then
683
863
  require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
684
864
  fi
685
- require_cmd curl "macOS/Linux: usually preinstalled check your package manager"
865
+ # curl is only used by the install path's health/validate probes; toggles never
866
+ # hit the network.
867
+ [ "$mode" = "install" ] && require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
686
868
 
687
869
  case "$target" in
688
870
  claude)
@@ -697,6 +879,12 @@ case "$target" in
697
879
  warn "Continuing — config.toml will be written and will take effect once Codex is installed."
698
880
  fi
699
881
  ;;
882
+ opencode)
883
+ if ! command -v opencode >/dev/null 2>&1; then
884
+ warn "'opencode' not found on PATH. Install from https://opencode.ai (or 'npm install -g opencode-ai'), then re-run this script."
885
+ warn "Continuing — opencode.json will be written and will take effect once opencode is installed."
886
+ fi
887
+ ;;
700
888
  esac
701
889
 
702
890
  script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
@@ -767,7 +955,7 @@ if [ "$target" = "claude" ]; then
767
955
  fi
768
956
 
769
957
  mkdir -p "$settings_dir" "$statusline_dir"
770
- else
958
+ elif [ "$target" = "codex" ]; then
771
959
  # Codex CLI reads config from ~/.codex/config.toml by default. For project
772
960
  # scope we write to <repo>/.codex/config.toml; the user invokes Codex with
773
961
  # `CODEX_HOME=<repo>/.codex codex` (or runs from the repo if Codex auto-
@@ -782,6 +970,296 @@ else
782
970
  fi
783
971
 
784
972
  mkdir -p "$codex_dir"
973
+ else
974
+ # opencode discovers config in this order: $XDG_CONFIG_HOME/opencode/opencode.json
975
+ # (or ~/.config/opencode/opencode.json) for user scope, and opencode.json /
976
+ # opencode.jsonc walked up from CWD for project scope. We standardize on
977
+ # opencode.json at the repo root for project scope (the option teammates can
978
+ # commit) and the XDG path for user scope. The router key is embedded so
979
+ # opencode.json goes in .gitignore for project scope, same as Codex.
980
+ case "$scope" in
981
+ user)
982
+ opencode_dir="${XDG_CONFIG_HOME:-$settings_base/.config}/opencode"
983
+ ;;
984
+ project)
985
+ opencode_dir="$settings_base"
986
+ ;;
987
+ esac
988
+ # --dir overrides both scopes: drop opencode.json straight into <dir>/ so
989
+ # the sandbox is self-contained (mirrors how --dir behaves for Codex).
990
+ if [ -n "$install_dir" ]; then
991
+ opencode_dir="$install_dir"
992
+ fi
993
+ opencode_config_file="$opencode_dir/opencode.json"
994
+
995
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
996
+ refuse_if_symlink "$opencode_dir"
997
+ refuse_if_symlink "$opencode_config_file"
998
+ fi
999
+
1000
+ mkdir -p "$opencode_dir"
1001
+ fi
1002
+
1003
+ # ---------- off / on / status (toggle an existing install) ----------
1004
+ #
1005
+ # Flip a client between the Weave Router and talking to its provider directly,
1006
+ # WITHOUT discarding the router config — so switching back is one command. We
1007
+ # only run this for the `off`/`on`/`status` verbs; `install` falls straight
1008
+ # through to the write path below. An explicit client was already required
1009
+ # during arg parsing, so exactly one of the toggle_* helpers fires.
1010
+ #
1011
+ # Per-client "off" mechanics (each leaves the router config in place so "on"
1012
+ # restores it byte-for-byte):
1013
+ # Claude Code — moves ANTHROPIC_BASE_URL + the key header out of settings
1014
+ # into a parked sidecar; CC falls back to its own login.
1015
+ # Codex — comments the `model_provider = "weave"` line inside the
1016
+ # managed block; the [model_providers.weave] section stays.
1017
+ # opencode — parks the top-level `model` and removes it; opencode reverts
1018
+ # to its own default. The provider.weave block stays.
1019
+ # Claude Code reads env at launch, so its toggle lands on the next `claude`
1020
+ # start; Codex/opencode re-read config every run.
1021
+
1022
+ # router_shaped_url returns 0 when a base URL points at the router — i.e. it's
1023
+ # neither empty nor Anthropic's own endpoint (what "off" falls back to).
1024
+ router_shaped_url() {
1025
+ case "$1" in
1026
+ ""|https://api.anthropic.com*|http://api.anthropic.com*) return 1 ;;
1027
+ *) return 0 ;;
1028
+ esac
1029
+ }
1030
+
1031
+ # json_get prints a jq scalar from a file, or empty when the file/key is
1032
+ # absent. Never trips set -e.
1033
+ json_get() {
1034
+ [ -f "$1" ] || return 0
1035
+ jq -r "${2} // empty" "$1" 2>/dev/null || true
1036
+ }
1037
+
1038
+ # gitignore_add appends an entry to the repo .gitignore in project scope so a
1039
+ # parked sidecar (which may carry the router key header) never gets committed.
1040
+ # No-op for user scope and --dir, matching how install handles its own ignores.
1041
+ gitignore_add() {
1042
+ [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ] || return 0
1043
+ local gi="$git_root/.gitignore" entry="$1"
1044
+ refuse_if_symlink "$gi"
1045
+ if [ ! -f "$gi" ] || ! grep -qxF "$entry" "$gi"; then
1046
+ printf '%s\n' "$entry" >>"$gi"
1047
+ fi
1048
+ }
1049
+
1050
+ toggle_claude() {
1051
+ local parked="$settings_dir/.weave-parked.json"
1052
+ local proj="false" active committed_base local_base parked_env merged
1053
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1054
+ proj="true"
1055
+ active="$local_settings_file"
1056
+ else
1057
+ active="$settings_file"
1058
+ fi
1059
+ local parked_present="false"
1060
+ if [ -f "$parked" ]; then parked_present="true"; fi
1061
+ committed_base="$(json_get "$settings_file" '.env.ANTHROPIC_BASE_URL')"
1062
+
1063
+ case "$mode" in
1064
+ status)
1065
+ local on_hint="on --claude"
1066
+ [ "$proj" = "true" ] && on_hint="on --claude --scope project"
1067
+ if [ "$parked_present" = "true" ]; then
1068
+ ok "Claude Code: ${C_BOLD}off${C_RESET} — routing directly to Anthropic. Run '$on_hint' to re-enable."
1069
+ elif [ "$proj" = "true" ]; then
1070
+ local_base="$(json_get "$local_settings_file" '.env.ANTHROPIC_BASE_URL')"
1071
+ if [ -n "$local_base" ] && ! router_shaped_url "$local_base"; then
1072
+ ok "Claude Code (project): ${C_BOLD}off${C_RESET} — routing directly to Anthropic. Run '$on_hint' to re-enable."
1073
+ elif router_shaped_url "$committed_base"; then
1074
+ ok "Claude Code (project): ${C_BOLD}on${C_RESET} — routing through $committed_base."
1075
+ else
1076
+ info "Claude Code (project): not configured for the router. Run the installer first."
1077
+ fi
1078
+ elif router_shaped_url "$committed_base"; then
1079
+ ok "Claude Code: ${C_BOLD}on${C_RESET} — routing through $committed_base."
1080
+ else
1081
+ info "Claude Code: not configured for the router. Run the installer first."
1082
+ fi
1083
+ ;;
1084
+ off)
1085
+ if [ "$parked_present" = "true" ]; then
1086
+ ok "Claude Code is already off — nothing to do."
1087
+ return 0
1088
+ fi
1089
+ if ! router_shaped_url "$committed_base"; then
1090
+ info "Claude Code isn't configured for the router. Run the installer first."
1091
+ return 0
1092
+ fi
1093
+ if [ "$proj" = "true" ]; then
1094
+ # Park the whole local env (carries the key header), then override the
1095
+ # base URL to Anthropic in the local file only — committed settings.json
1096
+ # is never touched, so this stays out of `git diff`.
1097
+ if [ -f "$local_settings_file" ]; then
1098
+ jq '{env: (.env // {})}' "$local_settings_file" >"$parked"
1099
+ merged="$(jq '.env = ((.env // {} | del(.ANTHROPIC_CUSTOM_HEADERS)) + {ANTHROPIC_BASE_URL: "https://api.anthropic.com"})' "$local_settings_file")"
1100
+ else
1101
+ printf '{"env":{}}\n' >"$parked"
1102
+ merged='{"env":{"ANTHROPIC_BASE_URL":"https://api.anthropic.com"}}'
1103
+ fi
1104
+ printf '%s\n' "$merged" >"$local_settings_file"
1105
+ chmod 600 "$local_settings_file" "$parked"
1106
+ gitignore_add ".claude/.weave-parked.json"
1107
+ else
1108
+ # Park just the router-owned env keys, then strip them so Claude Code
1109
+ # falls back to its own Anthropic login.
1110
+ jq '{env: ((.env // {}) | {ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS} | with_entries(select(.value != null)))}' "$settings_file" >"$parked"
1111
+ chmod 600 "$parked"
1112
+ merged="$(jq '(.env // {}) |= del(.ANTHROPIC_BASE_URL, .ANTHROPIC_CUSTOM_HEADERS)
1113
+ | (if (.env // {} | length) == 0 then del(.env) else . end)' "$settings_file")"
1114
+ printf '%s\n' "$merged" >"$settings_file"
1115
+ fi
1116
+ ok "Claude Code is now ${C_BOLD}off${C_RESET} (direct to Anthropic). Restart Claude Code for it to take effect."
1117
+ ;;
1118
+ on)
1119
+ if [ "$parked_present" != "true" ]; then
1120
+ # Project scope can still carry a direct-override in settings.local.json
1121
+ # even with no sidecar (e.g. the parked file was deleted by hand). That
1122
+ # override wins over the committed router URL, so traffic is really
1123
+ # going direct — drop it so "on" matches what "status" reports, instead
1124
+ # of falsely claiming we're already on.
1125
+ if [ "$proj" = "true" ]; then
1126
+ local_base="$(json_get "$local_settings_file" '.env.ANTHROPIC_BASE_URL')"
1127
+ if [ -n "$local_base" ] && ! router_shaped_url "$local_base"; then
1128
+ merged="$(jq '(.env // {}) |= del(.ANTHROPIC_BASE_URL)
1129
+ | (if (.env // {} | length) == 0 then del(.env) else . end)' "$local_settings_file")"
1130
+ printf '%s\n' "$merged" >"$local_settings_file"
1131
+ chmod 600 "$local_settings_file"
1132
+ ok "Claude Code is now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
1133
+ return 0
1134
+ fi
1135
+ fi
1136
+ if router_shaped_url "$committed_base"; then
1137
+ ok "Claude Code is already on — nothing to do."
1138
+ else
1139
+ warn "No parked router config found. Run the installer to set up Claude Code."
1140
+ fi
1141
+ return 0
1142
+ fi
1143
+ parked_env="$(jq '.env' "$parked")"
1144
+ merged="$(jq --argjson p "$parked_env" '.env = (((.env // {}) | del(.ANTHROPIC_BASE_URL)) + $p)' "$active")"
1145
+ printf '%s\n' "$merged" >"$active"
1146
+ [ "$proj" = "true" ] && chmod 600 "$active"
1147
+ rm -f "$parked"
1148
+ ok "Claude Code is now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Restart Claude Code for it to take effect."
1149
+ ;;
1150
+ esac
1151
+ }
1152
+
1153
+ toggle_codex() {
1154
+ local f="$codex_config_file" state="absent" tmp
1155
+ if [ -f "$f" ]; then
1156
+ state="$(awk -v b="$WEAVE_CODEX_BEGIN_MARKER" -v e="$WEAVE_CODEX_END_MARKER" '
1157
+ $0==b{inblk=1; next}
1158
+ $0==e{inblk=0; next}
1159
+ inblk && /^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"weave"/ {st="on"}
1160
+ inblk && /^[[:space:]]*#[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"weave"/ {if(st=="")st="off"}
1161
+ END{print (st==""?"absent":st)}
1162
+ ' "$f")"
1163
+ fi
1164
+
1165
+ case "$mode" in
1166
+ status)
1167
+ case "$state" in
1168
+ on) ok "Codex: ${C_BOLD}on${C_RESET} — routing through the Weave Router." ;;
1169
+ off) ok "Codex: ${C_BOLD}off${C_RESET} — using Codex's default provider. Run 'on --codex' to re-enable." ;;
1170
+ *) info "Codex: not configured for the router. Run the installer first." ;;
1171
+ esac
1172
+ ;;
1173
+ off)
1174
+ if [ "$state" = "absent" ]; then info "Codex isn't configured for the router. Run the installer first."; return 0; fi
1175
+ if [ "$state" = "off" ]; then ok "Codex is already off — nothing to do."; return 0; fi
1176
+ tmp="$(mktemp -t weave-codex-toggle.XXXXXX)"
1177
+ awk -v b="$WEAVE_CODEX_BEGIN_MARKER" -v e="$WEAVE_CODEX_END_MARKER" '
1178
+ $0==b{inblk=1; print; next}
1179
+ $0==e{inblk=0; print; next}
1180
+ inblk && /^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"weave"[[:space:]]*$/ {
1181
+ print "# " $0 " # weave-router: off (run on to re-enable)"; next
1182
+ }
1183
+ {print}
1184
+ ' "$f" >"$tmp" && mv "$tmp" "$f"
1185
+ chmod 600 "$f"
1186
+ ok "Codex is now ${C_BOLD}off${C_RESET} (default provider). Takes effect on your next 'codex' run."
1187
+ ;;
1188
+ on)
1189
+ if [ "$state" = "absent" ]; then warn "No managed Weave block in $f. Run the installer to set up Codex."; return 0; fi
1190
+ if [ "$state" = "on" ]; then ok "Codex is already on — nothing to do."; return 0; fi
1191
+ tmp="$(mktemp -t weave-codex-toggle.XXXXXX)"
1192
+ awk -v b="$WEAVE_CODEX_BEGIN_MARKER" -v e="$WEAVE_CODEX_END_MARKER" '
1193
+ $0==b{inblk=1; print; next}
1194
+ $0==e{inblk=0; print; next}
1195
+ inblk && /^[[:space:]]*#[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"weave"/ {
1196
+ print "model_provider = \"weave\""; next
1197
+ }
1198
+ {print}
1199
+ ' "$f" >"$tmp" && mv "$tmp" "$f"
1200
+ chmod 600 "$f"
1201
+ ok "Codex is now ${C_BOLD}on${C_RESET} (routing through the Weave Router). Takes effect on your next 'codex' run."
1202
+ ;;
1203
+ esac
1204
+ }
1205
+
1206
+ toggle_opencode() {
1207
+ local f="$opencode_config_file" parked="$opencode_dir/.weave-parked.json"
1208
+ local model="" has_weave="false" parked_present="false" on="false" restore_model merged
1209
+ if [ -f "$parked" ]; then parked_present="true"; fi
1210
+ if [ -f "$f" ]; then
1211
+ model="$(jq -r '.model // empty' "$f" 2>/dev/null || true)"
1212
+ if [ "$(jq -r '((.provider // {}) | has("weave"))' "$f" 2>/dev/null || true)" = "true" ]; then has_weave="true"; fi
1213
+ fi
1214
+ case "$model" in weave/*) on="true" ;; esac
1215
+
1216
+ case "$mode" in
1217
+ status)
1218
+ if [ "$on" = "true" ]; then
1219
+ ok "opencode: ${C_BOLD}on${C_RESET} — default model is $model (via the Weave Router)."
1220
+ elif [ "$has_weave" = "true" ] || [ "$parked_present" = "true" ]; then
1221
+ ok "opencode: ${C_BOLD}off${C_RESET} — not using the Weave Router model. Run 'on --opencode' to re-enable."
1222
+ else
1223
+ info "opencode: not configured for the router. Run the installer first."
1224
+ fi
1225
+ ;;
1226
+ off)
1227
+ if [ "$on" != "true" ]; then
1228
+ if [ "$has_weave" = "true" ]; then ok "opencode is already off — nothing to do."; else info "opencode isn't configured for the router. Run the installer first."; fi
1229
+ return 0
1230
+ fi
1231
+ jq '{model: .model}' "$f" >"$parked"
1232
+ chmod 600 "$parked"
1233
+ merged="$(jq 'del(.model)' "$f")"
1234
+ printf '%s\n' "$merged" >"$f"
1235
+ chmod 600 "$f"
1236
+ gitignore_add ".weave-parked.json"
1237
+ ok "opencode is now ${C_BOLD}off${C_RESET} — pick a non-Weave model with /models. Takes effect on your next opencode run."
1238
+ ;;
1239
+ on)
1240
+ if [ "$on" = "true" ]; then ok "opencode is already on — nothing to do."; return 0; fi
1241
+ restore_model="weave/claude-sonnet-4-6"
1242
+ if [ "$parked_present" = "true" ]; then
1243
+ restore_model="$(jq -r '.model // "weave/claude-sonnet-4-6"' "$parked")"
1244
+ elif [ "$has_weave" != "true" ]; then
1245
+ warn "opencode isn't configured for the router. Run the installer first."; return 0
1246
+ fi
1247
+ merged="$(jq --arg m "$restore_model" '.model = $m' "$f")"
1248
+ printf '%s\n' "$merged" >"$f"
1249
+ chmod 600 "$f"
1250
+ rm -f "$parked"
1251
+ ok "opencode is now ${C_BOLD}on${C_RESET} (default model $restore_model via the Weave Router). Takes effect on your next opencode run."
1252
+ ;;
1253
+ esac
1254
+ }
1255
+
1256
+ if [ "$mode" != "install" ]; then
1257
+ case "$target" in
1258
+ claude) toggle_claude ;;
1259
+ codex) toggle_codex ;;
1260
+ opencode) toggle_opencode ;;
1261
+ esac
1262
+ exit 0
785
1263
  fi
786
1264
 
787
1265
  # ---------- token handling ----------
@@ -876,16 +1354,44 @@ install_slash_commands() {
876
1354
  refuse_if_symlink "$dst_dir"
877
1355
  fi
878
1356
  mkdir -p "$dst_dir"
879
- for cmd in force-model unforce-model; do
1357
+
1358
+ # force-model/unforce-model are router-intercepted prompt expansions and
1359
+ # apply to every target. The router-off/on/status wrappers shell out to this
1360
+ # installer to flip the *local* config, so they're Claude Code-only and need
1361
+ # the install scope baked into the command (the .md can't discover it at
1362
+ # invocation time). {{SCOPE}} is substituted accordingly.
1363
+ installed="force-model, unforce-model"
1364
+ cmds="force-model unforce-model"
1365
+ if [ "$target" = "claude" ]; then
1366
+ cmds="$cmds router-off router-on router-status"
1367
+ installed="$installed, router-off, router-on, router-status"
1368
+ fi
1369
+
1370
+ # Bake the same scope selector the toggle needs to find this install: --dir
1371
+ # overrides scope (mirrors install/uninstall path resolution), so a sandbox
1372
+ # install gets `--dir <path>` and the slash commands flip that dir rather
1373
+ # than the default user-scope paths. printf %q quotes paths with spaces.
1374
+ local scope_args=""
1375
+ if [ -n "$install_dir" ]; then
1376
+ scope_args=" --dir $(printf '%q' "$install_dir")"
1377
+ elif [ "$scope" = "project" ]; then
1378
+ scope_args=" --scope project"
1379
+ fi
1380
+
1381
+ for cmd in $cmds; do
880
1382
  src="$commands_src_dir/$cmd.md"
881
1383
  dst="$dst_dir/$cmd.md"
882
1384
  [ -f "$src" ] || continue
883
1385
  if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
884
1386
  refuse_if_symlink "$dst"
885
1387
  fi
886
- cp "$src" "$dst"
1388
+ # Substitute the {{SCOPE}} placeholder (only the router-* wrappers carry it;
1389
+ # cp-equivalent for the others since the token is absent).
1390
+ local body; body="$(cat "$src")"
1391
+ body="${body//\{\{SCOPE\}\}/$scope_args}"
1392
+ printf '%s\n' "$body" >"$dst"
887
1393
  done
888
- ok "Slash commands written to $dst_dir (force-model, unforce-model)"
1394
+ ok "Slash commands written to $dst_dir ($installed)"
889
1395
  }
890
1396
 
891
1397
  # ---------- codex install path (dispatch + exit before the Claude-only writes) ----------
@@ -940,6 +1446,58 @@ if [ "$target" = "codex" ]; then
940
1446
  # point CODEX_HOME at the directory we wrote so Codex finds our config.
941
1447
  info "Run Codex with CODEX_HOME=$codex_dir codex so it picks up this config."
942
1448
  fi
1449
+ print_uninstall_hint
1450
+ exit 0
1451
+ fi
1452
+
1453
+ # ---------- opencode install path (dispatch + exit before the Claude-only writes) ----------
1454
+
1455
+ if [ "$target" = "opencode" ]; then
1456
+ write_opencode_config "$opencode_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
1457
+ ok "opencode config written to $opencode_config_file"
1458
+
1459
+ # Project scope: the per-teammate config carries the router key, so it
1460
+ # stays out of git. Same reasoning as the Codex path — base URL is shared,
1461
+ # but the key is per-person.
1462
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
1463
+ gitignore="$git_root/.gitignore"
1464
+ refuse_if_symlink "$gitignore"
1465
+ for entry in \
1466
+ "opencode.json"
1467
+ do
1468
+ if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
1469
+ printf '%s\n' "$entry" >>"$gitignore"
1470
+ fi
1471
+ done
1472
+ ok "Updated $gitignore (ignored opencode.json)"
1473
+ fi
1474
+
1475
+ # Post-install verification: same probes the Claude/Codex paths run.
1476
+ if [ "$quiet" != "true" ]; then
1477
+ if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
1478
+ warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
1479
+ fi
1480
+ fi
1481
+
1482
+ if [ -n "$api_key" ]; then
1483
+ validate_opencode_key() {
1484
+ printf '%s: %s\n' "$router_key_header" "$api_key" \
1485
+ | curl -fsS --max-time 5 --header @- "$base_url/validate"
1486
+ }
1487
+ if ! spin "Validating API key" validate_opencode_key; then
1488
+ warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
1489
+ fi
1490
+ fi
1491
+
1492
+ printf "\n"
1493
+ printf "%s✓%s %s%sWeave Router installed for opencode.%s\n" \
1494
+ "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
1495
+ if [ -n "$install_dir" ]; then
1496
+ # --dir installs land outside opencode's discovery roots, so the caller
1497
+ # has to point opencode at the file explicitly.
1498
+ info "Run opencode with OPENCODE_CONFIG=$opencode_config_file opencode."
1499
+ fi
1500
+ print_uninstall_hint
943
1501
  exit 0
944
1502
  fi
945
1503
 
@@ -1078,8 +1636,9 @@ normalize_model() {
1078
1636
  prices='{
1079
1637
  "input": {
1080
1638
  "claude-haiku-4-5": 0.0008,
1081
- "claude-opus-4-6": 0.015,
1082
- "claude-opus-4-7": 0.015,
1639
+ "claude-opus-4-6": 0.005,
1640
+ "claude-opus-4-7": 0.005,
1641
+ "claude-opus-4-8": 0.005,
1083
1642
  "claude-sonnet-4-5": 0.003,
1084
1643
  "claude-sonnet-4-6": 0.003,
1085
1644
  "deepseek/deepseek-v4-flash": 0.00014,
@@ -1112,20 +1671,25 @@ prices='{
1112
1671
  "gpt-5.5-nano": 0.00015,
1113
1672
  "gpt-5.5-pro": 0.03,
1114
1673
  "minimax/minimax-m2.7": 0.0003,
1674
+ "mistralai/mistral-small-2603": 0.0002,
1115
1675
  "moonshotai/kimi-k2.5": 0.0006,
1116
1676
  "moonshotai/kimi-k2.6": 0.00095,
1117
- "qwen/qwen3-235b-a22b-2507": 0.000071,
1677
+ "qwen/qwen3-235b-a22b-2507": 0.0002266,
1678
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.00015,
1679
+ "qwen/qwen3-coder": 0.0009,
1118
1680
  "qwen/qwen3-coder-next": 0.0005,
1119
1681
  "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
1682
+ "qwen/qwen3.5-flash-02-23": 0.00005,
1120
1683
  "qwen/qwen3.6-35b-a3b": 0.00015,
1121
- "xiaomi/mimo-v2.5": 0.0004,
1122
1684
  "xiaomi/mimo-v2.5-pro": 0.001,
1123
- "z-ai/glm-5": 0.0006
1685
+ "z-ai/glm-5": 0.0006,
1686
+ "z-ai/glm-5.1": 0.00105
1124
1687
  },
1125
1688
  "output": {
1126
1689
  "claude-haiku-4-5": 0.004,
1127
- "claude-opus-4-6": 0.075,
1128
- "claude-opus-4-7": 0.075,
1690
+ "claude-opus-4-6": 0.025,
1691
+ "claude-opus-4-7": 0.025,
1692
+ "claude-opus-4-8": 0.025,
1129
1693
  "claude-sonnet-4-5": 0.015,
1130
1694
  "claude-sonnet-4-6": 0.015,
1131
1695
  "deepseek/deepseek-v4-flash": 0.00028,
@@ -1158,15 +1722,19 @@ prices='{
1158
1722
  "gpt-5.5-nano": 0.0006,
1159
1723
  "gpt-5.5-pro": 0.12,
1160
1724
  "minimax/minimax-m2.7": 0.0012,
1725
+ "mistralai/mistral-small-2603": 0.0006,
1161
1726
  "moonshotai/kimi-k2.5": 0.003,
1162
1727
  "moonshotai/kimi-k2.6": 0.004,
1163
- "qwen/qwen3-235b-a22b-2507": 0.000463,
1728
+ "qwen/qwen3-235b-a22b-2507": 0.0009064,
1729
+ "qwen/qwen3-30b-a3b-instruct-2507": 0.0006,
1730
+ "qwen/qwen3-coder": 0.0027,
1164
1731
  "qwen/qwen3-coder-next": 0.0012,
1165
1732
  "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
1733
+ "qwen/qwen3.5-flash-02-23": 0.00015,
1166
1734
  "qwen/qwen3.6-35b-a3b": 0.00095,
1167
- "xiaomi/mimo-v2.5": 0.002,
1168
1735
  "xiaomi/mimo-v2.5-pro": 0.003,
1169
- "z-ai/glm-5": 0.00208
1736
+ "z-ai/glm-5": 0.00208,
1737
+ "z-ai/glm-5.1": 0.0035
1170
1738
  }
1171
1739
  }'
1172
1740
  # END_GENERATED_PRICES
@@ -1222,9 +1790,24 @@ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
1222
1790
  # The marker regex tolerates the optional "(<provider>)" segment and a
1223
1791
  # `[1m]` / `-YYYYMMDD` suffix on either model name so transcripts written
1224
1792
  # against context-tiered or dated model ids still parse cleanly.
1793
+ #
1794
+ # Dedup note: CC writes one JSONL entry per *content block* in an
1795
+ # assistant turn (text, text, tool_use → 3 entries), and every entry
1796
+ # carries the same `message.usage`. Summing per-entry triple-counts the
1797
+ # turn. We dedupe on (message.id, message.usage) before summing:
1798
+ # * For native Anthropic upstreams message.id is unique per turn, so
1799
+ # this collapses the content-block fan-out cleanly.
1800
+ # * For non-Anthropic upstreams that round-trip through the router's
1801
+ # translator, message.id can be a constant placeholder
1802
+ # ("msg_translated"); usage still differs per turn (input_tokens
1803
+ # grows), so the composite key keeps turns distinct. Two turns with
1804
+ # byte-identical id AND usage would still collapse, but that's a
1805
+ # genuine retry/duplicate we want to drop.
1225
1806
  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") |
1807
+ jq -rs --argjson p "$prices" --arg requested "$requested_norm" '
1808
+ [.[] | select(.type=="assistant")] |
1809
+ unique_by([.message.id, .message.usage]) |
1810
+ .[] |
1228
1811
  .message as $m |
1229
1812
  ($m.model // "" | sub("\\[[^]]*\\]$"; "") | sub("-[0-9]{8}$"; "")) as $rm |
1230
1813
  {
@@ -1452,3 +2035,4 @@ fi
1452
2035
  printf "\n"
1453
2036
  printf "%s✓%s %s%sWeave Router installed for Claude Code.%s\n" \
1454
2037
  "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
2038
+ print_uninstall_hint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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,90 @@ 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
+ # Drop the toggle parked sidecar (holds the parked router model when off).
177
+ opencode_parked="$opencode_dir/.weave-parked.json"
178
+ if [ -f "$opencode_parked" ]; then
179
+ refuse_if_symlink "$opencode_parked"
180
+ rm -f "$opencode_parked"
181
+ ok "Removed $opencode_parked"
182
+ fi
183
+
184
+ if [ -n "$install_dir" ]; then
185
+ ok "Weave Router uninstalled from $install_dir (opencode)."
186
+ else
187
+ ok "Weave Router uninstalled (opencode, scope=$scope)."
188
+ fi
189
+ exit 0
190
+ fi
191
+
100
192
  # ---------- codex uninstall path ----------
101
193
 
102
194
  if [ "$target" = "codex" ]; then
@@ -267,9 +359,12 @@ else
267
359
  fi
268
360
 
269
361
  if [ -n "$local_settings_file" ] && [ -f "$local_settings_file" ]; then
362
+ # ANTHROPIC_BASE_URL only lives here when a project install was toggled off
363
+ # (the off path overrides it to Anthropic in the local file); scrub it too so
364
+ # uninstall fully reverts a toggled-off install.
270
365
  cleaned="$(jq '
271
366
  if .env then
272
- .env |= (del(.ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS))
367
+ .env |= (del(.ANTHROPIC_BASE_URL, .ANTHROPIC_AUTH_TOKEN, .ANTHROPIC_CUSTOM_HEADERS))
273
368
  | (if (.env | length) == 0 then del(.env) else . end)
274
369
  else . end
275
370
  | del(.apiKeyHelper)
@@ -278,6 +373,14 @@ if [ -n "$local_settings_file" ] && [ -f "$local_settings_file" ]; then
278
373
  ok "Cleaned $local_settings_file"
279
374
  fi
280
375
 
376
+ # Drop the toggle parked sidecar (carries the router key header when off).
377
+ parked_file="$(dirname "$settings_file")/.weave-parked.json"
378
+ if [ -f "$parked_file" ]; then
379
+ refuse_if_symlink "$parked_file"
380
+ rm -f "$parked_file"
381
+ ok "Removed $parked_file"
382
+ fi
383
+
281
384
  for f in "$statusline_file"; do
282
385
  if [ -f "$f" ]; then
283
386
  rm -f "$f"
@@ -291,7 +394,7 @@ done
291
394
  commands_dir="$(dirname "$settings_file")/commands"
292
395
  if [ -d "$commands_dir" ]; then
293
396
  refuse_if_symlink "$commands_dir"
294
- for cmd in force-model unforce-model; do
397
+ for cmd in force-model unforce-model router-off router-on router-status; do
295
398
  cmd_file="$commands_dir/$cmd.md"
296
399
  if [ -f "$cmd_file" ]; then
297
400
  refuse_if_symlink "$cmd_file"