@workweave/router 0.1.2 → 0.1.4

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,26 +1,30 @@
1
1
  # @workweave/router
2
2
 
3
- One command, anywhere, to point Claude Code at the Weave Router.
3
+ One command, anywhere, to point Claude Code or Codex at the Weave Router.
4
4
 
5
5
  ```bash
6
- npx @workweave/router # hosted router, user scope (interactive)
7
- npx @workweave/router --scope project # per-repo install, commit settings.json
6
+ npx @workweave/router # interactive: pick Claude Code or Codex, then scope
7
+ npx @workweave/router --claude # skip the picker, target Claude Code
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/)
8
10
  npx @workweave/router --local # self-hosted via docker-compose (localhost:8080)
9
11
  npx @workweave/router --base-url https://router.acme.internal
10
- npx @workweave/router --non-interactive # reads $WEAVE_ROUTER_KEY, no prompts
12
+ npx @workweave/router --non-interactive # reads $WEAVE_ROUTER_KEY, no prompts (defaults to claude)
11
13
  ```
12
14
 
13
15
  Version-pin for reproducible setups:
14
16
 
15
17
  ```bash
16
- npx @workweave/router@0.1.0 --scope project
18
+ npx @workweave/router@0.1.0 --claude --scope project
17
19
  ```
18
20
 
19
21
  Uninstall:
20
22
 
21
23
  ```bash
22
- npx @workweave/router --uninstall # user scope
23
- npx @workweave/router --uninstall --scope project # in the repo
24
+ npx @workweave/router --uninstall # Claude Code, user scope
25
+ npx @workweave/router --uninstall --codex # Codex, user scope
26
+ npx @workweave/router --uninstall --scope project # Claude Code, inside the repo
27
+ npx @workweave/router --uninstall --codex --scope project
24
28
  ```
25
29
 
26
30
  ## What it does
@@ -28,8 +32,21 @@ npx @workweave/router --uninstall --scope project # in the repo
28
32
  This package is a thin Node wrapper around [`install.sh`](./install.sh) from
29
33
  the Weave Router repo. It exists so you can install from any machine with
30
34
  Node ≥ 18 — no `curl | sh`, no Git clone, no PATH fiddling. Everything the
31
- shell installer documents (scopes, flags, environment variables) works
32
- identically here.
35
+ shell installer documents (targets, scopes, flags, environment variables)
36
+ works identically here.
37
+
38
+ Two install targets:
39
+
40
+ - **Claude Code** (default) — patches `~/.claude/settings.json` (or
41
+ `<repo>/.claude/settings.json` with `--scope project`) so `claude` routes
42
+ through Weave automatically. Anthropic plan credentials flow through to
43
+ api.anthropic.com.
44
+ - **Codex** (`--codex`) — patches `~/.codex/config.toml` (or
45
+ `<repo>/.codex/config.toml`) with a managed `[model_providers.weave]`
46
+ block plus `model_provider = "weave"`. OpenAI plan credentials flow
47
+ through to api.openai.com. The block lives between begin/end markers so
48
+ re-running the installer rewrites it cleanly and `--uninstall --codex`
49
+ removes it without touching the rest of your config.
33
50
 
34
51
  See the [main installer docs](https://github.com/workweave/router/tree/main/install)
35
52
  for the full reference.
@@ -38,7 +55,8 @@ for the full reference.
38
55
 
39
56
  - Node ≥ 18 (ships with `npx`)
40
57
  - `bash` on PATH (macOS / Linux native; Windows needs Git Bash or WSL)
41
- - `jq` on PATH — used by the status line script
58
+ - `jq` on PATH — used by the Claude Code status line script. Not required
59
+ for the Codex path.
42
60
 
43
61
  ## Why npx
44
62
 
package/cc-statusline.sh CHANGED
@@ -133,8 +133,9 @@ prices='{
133
133
  "claude-opus-4-6": 0.015,
134
134
  "claude-opus-4-7": 0.015,
135
135
  "claude-sonnet-4-5": 0.003,
136
+ "claude-sonnet-4-6": 0.003,
136
137
  "deepseek/deepseek-v4-flash": 0.00014,
137
- "deepseek/deepseek-v4-pro": 0.000435,
138
+ "deepseek/deepseek-v4-pro": 0.00174,
138
139
  "gemini-2.0-flash": 0.0001,
139
140
  "gemini-2.0-flash-lite": 0.000075,
140
141
  "gemini-2.5-flash": 0.0003,
@@ -161,20 +162,25 @@ prices='{
161
162
  "gpt-5.5-mini": 0.0005,
162
163
  "gpt-5.5-nano": 0.00015,
163
164
  "gpt-5.5-pro": 0.03,
164
- "moonshotai/kimi-k2.5": 0.00044,
165
+ "minimax/minimax-m2.7": 0.0003,
166
+ "moonshotai/kimi-k2.5": 0.0006,
167
+ "moonshotai/kimi-k2.6": 0.00095,
165
168
  "qwen/qwen3-235b-a22b-2507": 0.000071,
166
- "qwen/qwen3-30b-a3b-instruct-2507": 0.00008,
167
- "qwen/qwen3-coder": 0.00022,
168
- "qwen/qwen3-coder-next": 0.00007,
169
- "qwen/qwen3-next-80b-a3b-instruct": 0.00009
169
+ "qwen/qwen3-coder-next": 0.0005,
170
+ "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
171
+ "qwen/qwen3.6-35b-a3b": 0.00015,
172
+ "xiaomi/mimo-v2.5": 0.0004,
173
+ "xiaomi/mimo-v2.5-pro": 0.001,
174
+ "z-ai/glm-5": 0.0006
170
175
  },
171
176
  "output": {
172
177
  "claude-haiku-4-5": 0.004,
173
178
  "claude-opus-4-6": 0.075,
174
179
  "claude-opus-4-7": 0.075,
175
180
  "claude-sonnet-4-5": 0.015,
181
+ "claude-sonnet-4-6": 0.015,
176
182
  "deepseek/deepseek-v4-flash": 0.00028,
177
- "deepseek/deepseek-v4-pro": 0.00087,
183
+ "deepseek/deepseek-v4-pro": 0.00348,
178
184
  "gemini-2.0-flash": 0.0004,
179
185
  "gemini-2.0-flash-lite": 0.0003,
180
186
  "gemini-2.5-flash": 0.0012,
@@ -201,12 +207,16 @@ prices='{
201
207
  "gpt-5.5-mini": 0.0025,
202
208
  "gpt-5.5-nano": 0.0006,
203
209
  "gpt-5.5-pro": 0.12,
204
- "moonshotai/kimi-k2.5": 0.002,
210
+ "minimax/minimax-m2.7": 0.0012,
211
+ "moonshotai/kimi-k2.5": 0.003,
212
+ "moonshotai/kimi-k2.6": 0.004,
205
213
  "qwen/qwen3-235b-a22b-2507": 0.000463,
206
- "qwen/qwen3-30b-a3b-instruct-2507": 0.00033,
207
- "qwen/qwen3-coder": 0.0018,
208
- "qwen/qwen3-coder-next": 0.0003,
209
- "qwen/qwen3-next-80b-a3b-instruct": 0.0011
214
+ "qwen/qwen3-coder-next": 0.0012,
215
+ "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
216
+ "qwen/qwen3.6-35b-a3b": 0.00095,
217
+ "xiaomi/mimo-v2.5": 0.002,
218
+ "xiaomi/mimo-v2.5-pro": 0.003,
219
+ "z-ai/glm-5": 0.00208
210
220
  }
211
221
  }'
212
222
  # END_GENERATED_PRICES
package/install.sh CHANGED
@@ -1,24 +1,33 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # Configures Claude Code to permanently route through the Weave Router by
4
- # writing the router base URL, router auth header, and a status line into
5
- # Claude Code's settings.json. After running, `claude` Just Works — no shell
6
- # exports, no manual settings edits.
3
+ # Weave Router installer for Claude Code and Codex.
7
4
  #
8
- # Two scopes:
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).
11
+ #
12
+ # Two scopes (apply to both targets):
9
13
  # - user (default): ~/.claude/settings.json + ~/.weave/cc-statusline.sh
14
+ # ~/.codex/config.toml (with --codex)
10
15
  # - project: <repo>/.claude/settings.json + <repo>/.claude/cc-statusline.sh
16
+ # <repo>/.codex/config.toml (with --codex)
11
17
  #
12
18
  # Or pass --dir to install into any directory:
13
19
  # - dir: <dir>/.claude/settings.json + <dir>/.claude/cc-statusline.sh
20
+ # <dir>/.codex/config.toml (with --codex)
14
21
  #
15
22
  # Usage:
16
- # npx @workweave/router # hosted router, user scope
23
+ # npx @workweave/router # interactive picker (Claude Code or Codex)
24
+ # npx @workweave/router --claude # skip the picker, target Claude Code
25
+ # npx @workweave/router --codex # skip the picker, target the OpenAI Codex CLI
17
26
  # npx @workweave/router --scope project # commit-with-team install
18
27
  # npx @workweave/router --dir /tmp/my-sandbox # isolated throwaway install
19
28
  # npx @workweave/router --local # local router on localhost:8080
20
29
  # npx @workweave/router --base-url http://localhost:8080 # self-hosted, custom port
21
- # npx @workweave/router --non-interactive # require WEAVE_ROUTER_KEY env var
30
+ # npx @workweave/router --non-interactive # require WEAVE_ROUTER_KEY env var (defaults target to claude)
22
31
  # npx @workweave/router --quiet # suppress banner, ping check, and trailing tips
23
32
  # npx @workweave/router --uninstall # remove a previous install (delegates to uninstall.sh)
24
33
 
@@ -37,6 +46,14 @@ base_url=""
37
46
  non_interactive="false"
38
47
  quiet="false"
39
48
  router_key_header="X-Weave-Router-Key"
49
+ # 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.
55
+ target="claude"
56
+ target_explicit="false"
40
57
 
41
58
  # ---------- helpers ----------
42
59
 
@@ -78,11 +95,16 @@ skip() { printf "%s⊙%s %s%s%s\n" "$C_DIM" "$C_RESET" "$C_DIM" "$*" "$C_RESET";
78
95
  print_banner() {
79
96
  [ "$quiet" = "true" ] && return 0
80
97
  [ "$tty_out" = "true" ] || return 0
98
+ local target_label
99
+ case "$target" in
100
+ codex) target_label="Codex installer" ;;
101
+ *) target_label="Claude Code installer" ;;
102
+ esac
81
103
  printf '\n'
82
104
  printf '%s ╦ ╦╔═╗╔═╗╦ ╦╔═╗%s\n' "$C_BRAND" "$C_RESET"
83
105
  printf '%s ║║║║╣ ╠═╣╚╗╔╝║╣ %s\n' "$C_BRAND" "$C_RESET"
84
106
  printf '%s ╚╩╝╚═╝╩ ╩ ╚╝ ╚═╝%s\n' "$C_BRAND" "$C_RESET"
85
- printf ' %sWeave Router · Claude Code installer%s\n\n' "$C_DIM" "$C_RESET"
107
+ printf ' %sWeave Router · %s%s\n\n' "$C_DIM" "$target_label" "$C_RESET"
86
108
  }
87
109
 
88
110
  # ---------- spinner ----------
@@ -207,6 +229,267 @@ refuse_if_symlink() {
207
229
  fi
208
230
  }
209
231
 
232
+ # Markers that delimit the block this installer manages inside Codex's
233
+ # config.toml. Kept on disk verbatim so a re-install (or uninstall.sh
234
+ # --codex) can find and replace the block instead of duplicating it.
235
+ WEAVE_CODEX_BEGIN_MARKER="# >>> weave-router managed (do not edit between markers) >>>"
236
+ WEAVE_CODEX_END_MARKER="# <<< weave-router managed <<<"
237
+
238
+ # ---------- identity helpers ----------
239
+ #
240
+ # The router parses X-Weave-User-Email and X-Weave-User-Name on every protocol
241
+ # (Anthropic, OpenAI, Gemini) and persists them onto router.model_router_users
242
+ # so customers can attribute traffic to a person even when many people share
243
+ # one API key. Claude Code's metadata.user_id carries only account_uuid (no
244
+ # email), so without these headers the router only ever sees anonymous UUIDs.
245
+
246
+ # normalize_email mirrors the router's proxy.NormalizeEmail: trim, lowercase,
247
+ # enforce a single '@' with non-empty local + domain parts, and cap at 254
248
+ # chars (RFC 5321). Returns the cleaned address on stdout, or empty string if
249
+ # the input is missing or malformed. We validate locally so the installer
250
+ # never plants a header value the router would silently drop, and so a
251
+ # typo'd git config doesn't end up as a per-request identity claim.
252
+ normalize_email() {
253
+ local raw="${1:-}"
254
+ # Trim whitespace then lowercase. tr is POSIX; the [:upper:]/[:lower:] form
255
+ # works on both macOS (BSD) and Linux (GNU) without needing LANG tweaks.
256
+ local trimmed="${raw#"${raw%%[![:space:]]*}"}"
257
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
258
+ local lowered
259
+ lowered="$(printf '%s' "$trimmed" | tr '[:upper:]' '[:lower:]')"
260
+ if [ -z "$lowered" ] || [ "${#lowered}" -gt 254 ]; then
261
+ printf ''
262
+ return
263
+ fi
264
+ # Reject any interior whitespace or control character so the value can't
265
+ # smuggle a second header into the newline-delimited ANTHROPIC_CUSTOM_HEADERS
266
+ # var. A valid email has none, so this is shape-only — not a deliverability
267
+ # check.
268
+ if printf '%s' "$lowered" | LC_ALL=C grep -q '[[:space:][:cntrl:]]'; then
269
+ printf ''
270
+ return
271
+ fi
272
+ case "$lowered" in
273
+ *@*@*) printf ''; return ;;
274
+ @*|*@) printf ''; return ;;
275
+ *@*) printf '%s' "$lowered" ;;
276
+ *) printf '' ;;
277
+ esac
278
+ }
279
+
280
+ # normalize_name trims whitespace, rejects empty/oversized, and strips control
281
+ # chars + the colon/CR/LF chars HTTP forbids in header values. Display names
282
+ # are free-form so we don't case-fold; we just keep the header well-formed.
283
+ normalize_name() {
284
+ local raw="${1:-}"
285
+ local trimmed="${raw#"${raw%%[![:space:]]*}"}"
286
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
287
+ # Drop CR/LF/colon (header smuggling) and other control chars. tr's -d
288
+ # with a character class is portable across BSD/GNU.
289
+ local cleaned
290
+ cleaned="$(printf '%s' "$trimmed" | tr -d '\r\n:' | tr -d '[:cntrl:]')"
291
+ if [ -z "$cleaned" ] || [ "${#cleaned}" -gt 128 ]; then
292
+ printf ''
293
+ return
294
+ fi
295
+ printf '%s' "$cleaned"
296
+ }
297
+
298
+ # resolve_user_email picks the email to plant in router request headers so the
299
+ # router can attribute traffic to a person even on shared API keys. Priority:
300
+ # WEAVE_USER_EMAIL env override → git config user.email → interactive prompt
301
+ # (pre-filled with whatever we found). In --non-interactive mode we never
302
+ # prompt, so unset/invalid means we ship no header (router treats that as
303
+ # account_uuid-only, same as today). Echoes the validated email on stdout.
304
+ resolve_user_email() {
305
+ local candidate=""
306
+ if [ -n "${WEAVE_USER_EMAIL:-}" ]; then
307
+ candidate="$(normalize_email "$WEAVE_USER_EMAIL")"
308
+ if [ -z "$candidate" ]; then
309
+ warn "WEAVE_USER_EMAIL=\"$WEAVE_USER_EMAIL\" is not a valid email; ignoring."
310
+ fi
311
+ fi
312
+ if [ -z "$candidate" ]; then
313
+ local git_email
314
+ git_email="$(git config --global user.email 2>/dev/null || true)"
315
+ candidate="$(normalize_email "$git_email")"
316
+ fi
317
+ if [ "$non_interactive" = "true" ] || [ ! -r /dev/tty ]; then
318
+ printf '%s' "$candidate"
319
+ return
320
+ fi
321
+ # Interactive: confirm/edit. Empty input keeps the suggested default; a
322
+ # literal `-` lets the user opt out (ship no header). This stays out of
323
+ # --quiet runs because --quiet implies the caller doesn't want prompts;
324
+ # they can still use WEAVE_USER_EMAIL to provide one explicitly.
325
+ if [ "$quiet" = "true" ]; then
326
+ printf '%s' "$candidate"
327
+ return
328
+ fi
329
+ local prompt_default="$candidate"
330
+ local response=""
331
+ if [ -n "$prompt_default" ]; then
332
+ printf "%sIdentify router traffic as %s[%s]%s (Enter to accept, '-' to skip): " \
333
+ "$C_DIM" "$C_BOLD" "$prompt_default" "$C_RESET" >/dev/tty
334
+ else
335
+ printf "%sEmail to identify your router traffic (blank to skip): %s" \
336
+ "$C_DIM" "$C_RESET" >/dev/tty
337
+ fi
338
+ read -r response </dev/tty || response=""
339
+ case "$response" in
340
+ "") printf '%s' "$prompt_default" ;;
341
+ "-") printf '' ;;
342
+ *)
343
+ local cleaned
344
+ cleaned="$(normalize_email "$response")"
345
+ if [ -z "$cleaned" ]; then
346
+ warn "\"$response\" is not a valid email; skipping identity header."
347
+ fi
348
+ printf '%s' "$cleaned"
349
+ ;;
350
+ esac
351
+ }
352
+
353
+ # write_codex_config writes a managed [model_providers.weave] block to the
354
+ # Codex CLI's config.toml. Sets `model_provider = "weave"` at the top level so
355
+ # Codex picks the routed provider by default. Both lines live inside the
356
+ # managed-block markers so uninstall removes them cleanly. We strip any
357
+ # top-level `model_provider = ...` declaration OUTSIDE the markers before
358
+ # appending so the file doesn't end up with a duplicate key (TOML rejects
359
+ # that). Inline `model_provider` keys inside `[profiles.*]` sections stay
360
+ # untouched.
361
+ #
362
+ # Usage: write_codex_config <config_file_path> <base_url> <api_key> [user_email] [user_name]
363
+ write_codex_config() {
364
+ local config_file="$1"
365
+ local block_url="$2"
366
+ local block_key="$3"
367
+ local block_email="${4:-}"
368
+ local block_name="${5:-}"
369
+
370
+ # Escape `\` and `"` for TOML basic strings. Order matters: replace
371
+ # backslashes first so the quotes we add next aren't double-escaped. A
372
+ # display name like `John "J" Doe` would otherwise produce invalid TOML and
373
+ # Codex would silently fail to parse config.toml — the installer's success
374
+ # message wouldn't help diagnose. Router keys are alnum+`_` from the API so
375
+ # safe as-is, but we escape uniformly for defense-in-depth.
376
+ toml_escape() {
377
+ local s="${1//\\/\\\\}"
378
+ printf '%s' "${s//\"/\\\"}"
379
+ }
380
+
381
+ local esc_key esc_email esc_name esc_url
382
+ esc_key="$(toml_escape "$block_key")"
383
+ esc_email="$(toml_escape "$block_email")"
384
+ esc_name="$(toml_escape "$block_name")"
385
+ esc_url="$(toml_escape "$block_url")"
386
+
387
+ # Plant whichever identity values we have alongside the router key so the
388
+ # router can attribute Codex traffic to a person on shared keys. Build the
389
+ # entries piecewise so an empty email/name is omitted entirely — the router
390
+ # never sees a header with no value (and TOML rejects empty unquoted vals).
391
+ local headers_parts="\"X-Weave-Router-Key\" = \"${esc_key}\""
392
+ if [ -n "$block_email" ]; then
393
+ headers_parts="${headers_parts}, \"X-Weave-User-Email\" = \"${esc_email}\""
394
+ fi
395
+ if [ -n "$block_name" ]; then
396
+ headers_parts="${headers_parts}, \"X-Weave-User-Name\" = \"${esc_name}\""
397
+ fi
398
+ local headers_line="http_headers = { ${headers_parts} }"
399
+
400
+ local block
401
+ block="$(cat <<TOML
402
+ ${WEAVE_CODEX_BEGIN_MARKER}
403
+ # Managed by the Weave Router installer. Re-running the installer rewrites
404
+ # this block; \`./uninstall.sh --codex\` removes it. To opt out without
405
+ # uninstalling, change the model_provider value below.
406
+ model_provider = "weave"
407
+
408
+ [model_providers.weave]
409
+ name = "Weave Router"
410
+ base_url = "${esc_url}/v1"
411
+ wire_api = "responses"
412
+ ${headers_line}
413
+ ${WEAVE_CODEX_END_MARKER}
414
+ TOML
415
+ )"
416
+
417
+ if [ -f "$config_file" ]; then
418
+ local tmp; tmp="$(mktemp -t weave-codex.XXXXXX)"
419
+ # Strip the managed block (between markers) plus any top-level
420
+ # `model_provider =` outside it. We define "top-level" as everything
421
+ # before the first `[section]` header. The awk handles both passes in
422
+ # one sweep so we never emit a duplicate.
423
+ awk -v begin="$WEAVE_CODEX_BEGIN_MARKER" -v end="$WEAVE_CODEX_END_MARKER" '
424
+ $0 == begin { skip = 1; next }
425
+ $0 == end { skip = 0; next }
426
+ skip { next }
427
+ /^[[:space:]]*\[/ { in_section = 1 }
428
+ !in_section && /^[[:space:]]*model_provider[[:space:]]*=/ { next }
429
+ { print }
430
+ ' "$config_file" >"$tmp"
431
+
432
+ # Insert the managed block at TOML top-level scope, NOT end-of-file. In
433
+ # TOML, every bare key after a `[section]` header belongs to that
434
+ # section, so appending `model_provider = "weave"` after a user's
435
+ # existing `[profiles.foo]` would silently scope it as
436
+ # `profiles.foo.model_provider` — Codex would never see the top-level
437
+ # default and routing would silently fail to activate. We splice the
438
+ # block in just before the first user section header so:
439
+ # <user's top-level keys> ← still top-level
440
+ # <our managed block> ← model_provider stays top-level
441
+ # [model_providers.weave] ← scoped section, OK anywhere
442
+ # <user's sections> ← re-scope, unaffected
443
+ local first_section
444
+ first_section="$(awk '/^[[:space:]]*\[/ { print NR; exit }' "$tmp")"
445
+ if [ -n "$first_section" ]; then
446
+ # BSD `head -n 0` (macOS default) errors with "illegal line count"
447
+ # and trips `set -euo pipefail`, leaving an empty config. Skip the
448
+ # head call entirely when the file starts with a section header.
449
+ {
450
+ if [ "$first_section" -gt 1 ]; then
451
+ head -n "$((first_section - 1))" "$tmp"
452
+ fi
453
+ printf "%s\n" "$block"
454
+ tail -n "+${first_section}" "$tmp"
455
+ } >"$config_file"
456
+ else
457
+ # No section headers in the existing file — every prior user key was
458
+ # already at top-level. Our block ends with its own [section], so
459
+ # appending is safe (no bare keys follow).
460
+ cp "$tmp" "$config_file"
461
+ printf "\n%s\n" "$block" >>"$config_file"
462
+ fi
463
+ rm -f "$tmp"
464
+ else
465
+ printf "%s\n" "$block" >"$config_file"
466
+ fi
467
+ # 0600: the file holds a router key. Even at user scope, mode 644 would
468
+ # leak the key to any local user on a shared box.
469
+ chmod 600 "$config_file"
470
+ }
471
+
472
+ # resolve_user_name mirrors resolve_user_email but for display name. Priority:
473
+ # WEAVE_USER_NAME env override → git config user.name → empty. We don't
474
+ # prompt for name independently: if email prompting yielded nothing, name
475
+ # almost certainly will too, and a second prompt is noise. Echoes the
476
+ # validated name on stdout.
477
+ resolve_user_name() {
478
+ local candidate=""
479
+ if [ -n "${WEAVE_USER_NAME:-}" ]; then
480
+ candidate="$(normalize_name "$WEAVE_USER_NAME")"
481
+ if [ -z "$candidate" ]; then
482
+ warn "WEAVE_USER_NAME=\"$WEAVE_USER_NAME\" is not a usable name; ignoring."
483
+ fi
484
+ fi
485
+ if [ -z "$candidate" ]; then
486
+ local git_name
487
+ git_name="$(git config --global user.name 2>/dev/null || true)"
488
+ candidate="$(normalize_name "$git_name")"
489
+ fi
490
+ printf '%s' "$candidate"
491
+ }
492
+
210
493
  # ---------- uninstall delegation ----------
211
494
  #
212
495
  # `--uninstall` flips this script into a thin shim for uninstall.sh: the
@@ -285,6 +568,14 @@ while [ $# -gt 0 ]; do
285
568
  install_dir="${2:-}"; shift 2
286
569
  [ -n "$install_dir" ] || { err "--dir requires a path"; exit 2; }
287
570
  ;;
571
+ --codex)
572
+ target="codex"; target_explicit="true"; shift
573
+ ;;
574
+ --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.
577
+ target="claude"; target_explicit="true"; shift
578
+ ;;
288
579
  -h|--help)
289
580
  usage 0
290
581
  ;;
@@ -300,9 +591,31 @@ fi
300
591
  # trim trailing slash for cleanliness
301
592
  base_url="${base_url%/}"
302
593
 
594
+ # ---------- interactive target prompt ----------
595
+
596
+ # If neither --claude nor --codex was passed and we have a controlling
597
+ # terminal, ask which tool to install for. Non-interactive runs (CI,
598
+ # `curl | sh --non-interactive`) silently use the "claude" default — same
599
+ # behavior the script had before --codex existed, so existing pipelines
600
+ # don't change semantics. We prompt BEFORE print_banner so the banner's
601
+ # target label (Claude Code installer / Codex installer) reflects the choice.
602
+ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r /dev/tty ]; then
603
+ printf "%sInstall target:%s\n" "$C_BOLD" "$C_RESET"
604
+ printf " %s1)%s Claude Code %s— patches ~/.claude/settings.json (or <repo>/.claude/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
605
+ 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"
607
+ read -r target_choice </dev/tty || target_choice=""
608
+ case "${target_choice:-1}" in
609
+ 1|""|claude|c|C) target="claude" ;;
610
+ 2|codex|x|X) target="codex" ;;
611
+ *) err "invalid choice: $target_choice"; exit 2 ;;
612
+ esac
613
+ fi
614
+
303
615
  # Banner runs before the interactive scope prompt so the very first thing
304
616
  # users see when `make full-setup` hands off to install.sh is the wordmark,
305
- # not a bare "Install scope:" line.
617
+ # not a bare "Install scope:" line. Target prompt above already finalized
618
+ # $target, so the banner's per-target label reflects the user's choice.
306
619
  print_banner
307
620
 
308
621
  # ---------- interactive scope prompt ----------
@@ -311,9 +624,22 @@ print_banner
311
624
  # scope to install into. Non-interactive runs (CI, `curl | sh --non-interactive`)
312
625
  # silently use the "user" default.
313
626
  if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r /dev/tty ]; then
627
+ # Per-target paths so the prompt text matches what actually gets written.
628
+ case "$target" in
629
+ codex)
630
+ scope_user_path="~/.codex/"
631
+ scope_project_path="<repo>/.codex/"
632
+ scope_cli_label="codex"
633
+ ;;
634
+ *)
635
+ scope_user_path="~/.claude/"
636
+ scope_project_path="<repo>/.claude/"
637
+ scope_cli_label="claude"
638
+ ;;
639
+ esac
314
640
  printf "%sInstall scope:%s\n" "$C_BOLD" "$C_RESET"
315
- printf " %s1)%s user %s— write to ~/.claude/ (applies everywhere you run claude)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
316
- printf " %s2)%s project %s— write to <repo>/.claude/ (applies only inside this repo)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
641
+ printf " %s1)%s user %s— write to %s (applies everywhere you run %s)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$scope_user_path" "$scope_cli_label" "$C_RESET"
642
+ printf " %s2)%s project %s— write to %s (applies only inside this repo)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$scope_project_path" "$C_RESET"
317
643
  printf "Choose %s[1/2]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
318
644
  read -r scope_choice </dev/tty || scope_choice=""
319
645
  case "${scope_choice:-1}" in
@@ -345,15 +671,29 @@ fi
345
671
 
346
672
  # ---------- pre-flight ----------
347
673
 
348
- [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
674
+ [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
349
675
 
350
- require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
676
+ # 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
679
+ require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
680
+ fi
351
681
  require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
352
682
 
353
- if ! command -v claude >/dev/null 2>&1; then
354
- warn "'claude' not found on PATH. Install Claude Code from https://claude.com/code, then re-run this script."
355
- warn "Continuing settings.json will be written and will take effect once Claude Code is installed."
356
- fi
683
+ case "$target" in
684
+ claude)
685
+ if ! command -v claude >/dev/null 2>&1; then
686
+ warn "'claude' not found on PATH. Install Claude Code from https://claude.com/code, then re-run this script."
687
+ warn "Continuing — settings.json will be written and will take effect once Claude Code is installed."
688
+ fi
689
+ ;;
690
+ codex)
691
+ if ! command -v codex >/dev/null 2>&1; then
692
+ warn "'codex' not found on PATH. Install via 'npm install -g @openai/codex' (or brew install codex), then re-run this script."
693
+ warn "Continuing — config.toml will be written and will take effect once Codex is installed."
694
+ fi
695
+ ;;
696
+ esac
357
697
 
358
698
  script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
359
699
 
@@ -386,42 +726,59 @@ else
386
726
  esac
387
727
  fi
388
728
 
389
- case "$scope" in
390
- user)
391
- settings_dir="$settings_base/.claude"
392
- settings_file="$settings_dir/settings.json"
393
- local_settings_file=""
394
- statusline_dir="${settings_base}/.weave"
395
- statusline_file="$statusline_dir/cc-statusline.sh"
396
- statusline_path_for_settings="$statusline_file"
397
- ;;
398
- project)
399
- settings_dir="$settings_base/.claude"
400
- settings_file="$settings_dir/settings.json"
401
- local_settings_file="$settings_dir/settings.local.json"
402
- statusline_dir="$settings_base/.claude"
403
- statusline_file="$statusline_dir/cc-statusline.sh"
404
- # Portable relative path for real repos (teammates can clone anywhere).
405
- # Absolute path when --dir overrides (no meaningful $CLAUDE_PROJECT_DIR).
406
- if [ -z "$install_dir" ]; then
407
- statusline_path_for_settings="\${CLAUDE_PROJECT_DIR}/.claude/cc-statusline.sh"
408
- else
729
+ if [ "$target" = "claude" ]; then
730
+ case "$scope" in
731
+ user)
732
+ settings_dir="$settings_base/.claude"
733
+ settings_file="$settings_dir/settings.json"
734
+ local_settings_file=""
735
+ statusline_dir="${settings_base}/.weave"
736
+ statusline_file="$statusline_dir/cc-statusline.sh"
409
737
  statusline_path_for_settings="$statusline_file"
410
- fi
411
- ;;
412
- esac
738
+ ;;
739
+ project)
740
+ settings_dir="$settings_base/.claude"
741
+ settings_file="$settings_dir/settings.json"
742
+ local_settings_file="$settings_dir/settings.local.json"
743
+ statusline_dir="$settings_base/.claude"
744
+ statusline_file="$statusline_dir/cc-statusline.sh"
745
+ # Portable relative path for real repos (teammates can clone anywhere).
746
+ # Absolute path when --dir overrides (no meaningful $CLAUDE_PROJECT_DIR).
747
+ if [ -z "$install_dir" ]; then
748
+ statusline_path_for_settings="\${CLAUDE_PROJECT_DIR}/.claude/cc-statusline.sh"
749
+ else
750
+ statusline_path_for_settings="$statusline_file"
751
+ fi
752
+ ;;
753
+ esac
413
754
 
414
- # Symlink containment: refuse if any target path is a symlink. User-scope paths
415
- # under $HOME are trusted; project-scope and --dir paths come from a git repo or
416
- # user-supplied directory that may be hostile, so we check those.
417
- if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
418
- refuse_if_symlink "$settings_dir"
419
- refuse_if_symlink "$settings_file"
420
- refuse_if_symlink "$local_settings_file"
421
- refuse_if_symlink "$statusline_file"
422
- fi
755
+ # Symlink containment: refuse if any target path is a symlink. User-scope
756
+ # paths under $HOME are trusted; project-scope and --dir paths come from a
757
+ # git repo or user-supplied directory that may be hostile, so we check those.
758
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
759
+ refuse_if_symlink "$settings_dir"
760
+ refuse_if_symlink "$settings_file"
761
+ refuse_if_symlink "$local_settings_file"
762
+ refuse_if_symlink "$statusline_file"
763
+ fi
423
764
 
424
- mkdir -p "$settings_dir" "$statusline_dir"
765
+ mkdir -p "$settings_dir" "$statusline_dir"
766
+ else
767
+ # Codex CLI reads config from ~/.codex/config.toml by default. For project
768
+ # scope we write to <repo>/.codex/config.toml; the user invokes Codex with
769
+ # `CODEX_HOME=<repo>/.codex codex` (or runs from the repo if Codex auto-
770
+ # discovers). The router key is embedded in the file so it stays per-
771
+ # teammate — .codex/config.toml goes in .gitignore in project scope.
772
+ codex_dir="$settings_base/.codex"
773
+ codex_config_file="$codex_dir/config.toml"
774
+
775
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
776
+ refuse_if_symlink "$codex_dir"
777
+ refuse_if_symlink "$codex_config_file"
778
+ fi
779
+
780
+ mkdir -p "$codex_dir"
781
+ fi
425
782
 
426
783
  # ---------- token handling ----------
427
784
 
@@ -453,6 +810,89 @@ if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
453
810
  [ -n "$api_key" ] || { err "no key provided"; exit 1; }
454
811
  fi
455
812
 
813
+ # ---------- identity (user email + name) ----------
814
+ #
815
+ # The router parses X-Weave-User-Email and X-Weave-User-Name on every protocol
816
+ # (Anthropic, OpenAI/Codex, Gemini) and persists them onto
817
+ # router.model_router_users so customers can attribute traffic to a person even
818
+ # when many people share one API key. We plant the headers at install time
819
+ # because Claude Code's metadata.user_id payload carries only account_uuid (no
820
+ # email), and Codex carries no identity at all — without this step the router
821
+ # only ever sees anonymous UUIDs for non-OTLP customers.
822
+ #
823
+ # Gate name on email: when the user explicitly opts out of email identity (via
824
+ # '-' at the prompt or by clearing git config), don't auto-plant a name from
825
+ # git config either. Opt-out should be all-or-nothing so the router
826
+ # consistently sees zero identity headers when the user wants to stay
827
+ # anonymous.
828
+ user_email="$(resolve_user_email)"
829
+ if [ -n "$user_email" ]; then
830
+ user_name="$(resolve_user_name)"
831
+ else
832
+ user_name=""
833
+ fi
834
+ if [ -n "$user_email" ] && [ -n "$user_name" ]; then
835
+ ok "Will identify router traffic as $user_name <$user_email>"
836
+ elif [ -n "$user_email" ]; then
837
+ ok "Will identify router traffic as $user_email"
838
+ else
839
+ info "No identity set — router traffic will be attributed by account UUID only."
840
+ fi
841
+
842
+ # ---------- codex install path (dispatch + exit before the Claude-only writes) ----------
843
+
844
+ if [ "$target" = "codex" ]; then
845
+ write_codex_config "$codex_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
846
+ ok "Codex config written to $codex_config_file"
847
+
848
+ # Project scope: ensure the per-teammate config (which holds the router key)
849
+ # is gitignored. The base URL is the same for every teammate, so a
850
+ # committed shared file would still leak the per-key portion. Easier to
851
+ # ignore the whole config and have each teammate run the installer.
852
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
853
+ gitignore="$git_root/.gitignore"
854
+ refuse_if_symlink "$gitignore"
855
+ for entry in \
856
+ ".codex/config.toml"
857
+ do
858
+ if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
859
+ printf '%s\n' "$entry" >>"$gitignore"
860
+ fi
861
+ done
862
+ ok "Updated $gitignore (ignored .codex/config.toml)"
863
+ fi
864
+
865
+ # Post-install verification: same probes the Claude path runs so a working
866
+ # install gives the same green checks regardless of target.
867
+ if [ "$quiet" != "true" ]; then
868
+ if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
869
+ warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
870
+ fi
871
+ fi
872
+
873
+ if [ -n "$api_key" ]; then
874
+ # Pass the router key via stdin (`@-`) instead of -H so it never lands in
875
+ # the process arg list. Mirrors the Claude-path validate logic.
876
+ validate_codex_key() {
877
+ printf '%s: %s\n' "$router_key_header" "$api_key" \
878
+ | curl -fsS --max-time 5 --header @- "$base_url/validate"
879
+ }
880
+ if ! spin "Validating API key" validate_codex_key; then
881
+ warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
882
+ fi
883
+ fi
884
+
885
+ printf "\n"
886
+ printf "%s✓%s %s%sWeave Router installed for Codex.%s\n" \
887
+ "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
888
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
889
+ # Codex auto-discovers ~/.codex; for non-user installs the caller has to
890
+ # point CODEX_HOME at the directory we wrote so Codex finds our config.
891
+ info "Run Codex with CODEX_HOME=$codex_dir codex so it picks up this config."
892
+ fi
893
+ exit 0
894
+ fi
895
+
456
896
  # ---------- write the statusline script ----------
457
897
 
458
898
  cat > "$statusline_file" << 'STATUSLINE_EOF'
@@ -591,8 +1031,9 @@ prices='{
591
1031
  "claude-opus-4-6": 0.015,
592
1032
  "claude-opus-4-7": 0.015,
593
1033
  "claude-sonnet-4-5": 0.003,
1034
+ "claude-sonnet-4-6": 0.003,
594
1035
  "deepseek/deepseek-v4-flash": 0.00014,
595
- "deepseek/deepseek-v4-pro": 0.000435,
1036
+ "deepseek/deepseek-v4-pro": 0.00174,
596
1037
  "gemini-2.0-flash": 0.0001,
597
1038
  "gemini-2.0-flash-lite": 0.000075,
598
1039
  "gemini-2.5-flash": 0.0003,
@@ -619,20 +1060,25 @@ prices='{
619
1060
  "gpt-5.5-mini": 0.0005,
620
1061
  "gpt-5.5-nano": 0.00015,
621
1062
  "gpt-5.5-pro": 0.03,
622
- "moonshotai/kimi-k2.5": 0.00044,
1063
+ "minimax/minimax-m2.7": 0.0003,
1064
+ "moonshotai/kimi-k2.5": 0.0006,
1065
+ "moonshotai/kimi-k2.6": 0.00095,
623
1066
  "qwen/qwen3-235b-a22b-2507": 0.000071,
624
- "qwen/qwen3-30b-a3b-instruct-2507": 0.00008,
625
- "qwen/qwen3-coder": 0.00022,
626
- "qwen/qwen3-coder-next": 0.00007,
627
- "qwen/qwen3-next-80b-a3b-instruct": 0.00009
1067
+ "qwen/qwen3-coder-next": 0.0005,
1068
+ "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
1069
+ "qwen/qwen3.6-35b-a3b": 0.00015,
1070
+ "xiaomi/mimo-v2.5": 0.0004,
1071
+ "xiaomi/mimo-v2.5-pro": 0.001,
1072
+ "z-ai/glm-5": 0.0006
628
1073
  },
629
1074
  "output": {
630
1075
  "claude-haiku-4-5": 0.004,
631
1076
  "claude-opus-4-6": 0.075,
632
1077
  "claude-opus-4-7": 0.075,
633
1078
  "claude-sonnet-4-5": 0.015,
1079
+ "claude-sonnet-4-6": 0.015,
634
1080
  "deepseek/deepseek-v4-flash": 0.00028,
635
- "deepseek/deepseek-v4-pro": 0.00087,
1081
+ "deepseek/deepseek-v4-pro": 0.00348,
636
1082
  "gemini-2.0-flash": 0.0004,
637
1083
  "gemini-2.0-flash-lite": 0.0003,
638
1084
  "gemini-2.5-flash": 0.0012,
@@ -659,12 +1105,16 @@ prices='{
659
1105
  "gpt-5.5-mini": 0.0025,
660
1106
  "gpt-5.5-nano": 0.0006,
661
1107
  "gpt-5.5-pro": 0.12,
662
- "moonshotai/kimi-k2.5": 0.002,
1108
+ "minimax/minimax-m2.7": 0.0012,
1109
+ "moonshotai/kimi-k2.5": 0.003,
1110
+ "moonshotai/kimi-k2.6": 0.004,
663
1111
  "qwen/qwen3-235b-a22b-2507": 0.000463,
664
- "qwen/qwen3-30b-a3b-instruct-2507": 0.00033,
665
- "qwen/qwen3-coder": 0.0018,
666
- "qwen/qwen3-coder-next": 0.0003,
667
- "qwen/qwen3-next-80b-a3b-instruct": 0.0011
1112
+ "qwen/qwen3-coder-next": 0.0012,
1113
+ "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
1114
+ "qwen/qwen3.6-35b-a3b": 0.00095,
1115
+ "xiaomi/mimo-v2.5": 0.002,
1116
+ "xiaomi/mimo-v2.5-pro": 0.003,
1117
+ "z-ai/glm-5": 0.00208
668
1118
  }
669
1119
  }'
670
1120
  # END_GENERATED_PRICES
@@ -835,13 +1285,26 @@ tmp_patch="$(mktemp)"
835
1285
  # leave the cursor hidden if Ctrl-C lands during settings.json patching.
836
1286
  trap '_spin_cleanup; rm -f "$tmp_patch"' EXIT INT TERM HUP
837
1287
 
1288
+ # Claude Code splits ANTHROPIC_CUSTOM_HEADERS on newlines, so multiple headers
1289
+ # ride in the same env var separated by \n. Append identity headers alongside
1290
+ # the router key so a single var carries them all. When email/name are empty
1291
+ # we keep the bare router-key form so a re-install for a user who opted out
1292
+ # cleanly removes the old line.
1293
+ custom_headers="$router_key_header: $api_key"
1294
+ if [ -n "$user_email" ]; then
1295
+ custom_headers="$custom_headers"$'\n'"X-Weave-User-Email: $user_email"
1296
+ fi
1297
+ if [ -n "$user_name" ]; then
1298
+ custom_headers="$custom_headers"$'\n'"X-Weave-User-Name: $user_name"
1299
+ fi
1300
+
838
1301
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
839
1302
  jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
840
1303
  env: { ANTHROPIC_BASE_URL: $url },
841
1304
  statusLine: { type: "command", command: $sl }
842
1305
  }' >"$tmp_patch"
843
1306
  else
844
- jq -n --arg url "$base_url" --arg header "$router_key_header: $api_key" --arg sl "$statusline_path_for_settings" '{
1307
+ jq -n --arg url "$base_url" --arg header "$custom_headers" --arg sl "$statusline_path_for_settings" '{
845
1308
  env: { ANTHROPIC_BASE_URL: $url, ANTHROPIC_CUSTOM_HEADERS: $header },
846
1309
  statusLine: { type: "command", command: $sl }
847
1310
  }' >"$tmp_patch"
@@ -867,7 +1330,7 @@ fi
867
1330
  ok "Settings written to $settings_file"
868
1331
 
869
1332
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
870
- jq -n --arg header "$router_key_header: $api_key" '{
1333
+ jq -n --arg header "$custom_headers" '{
871
1334
  env: { ANTHROPIC_CUSTOM_HEADERS: $header }
872
1335
  }' >"$tmp_patch"
873
1336
  if [ -f "$local_settings_file" ]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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,12 +1,16 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
- # Weave Router uninstaller for Claude Code.
3
+ # Weave Router uninstaller for Claude Code and Codex.
4
4
  #
5
- # Removes the env vars, statusLine, and local router auth that install.sh added.
6
- # Leaves the rest of settings.json untouched.
5
+ # Default target is Claude Code: removes the env vars, statusLine, and local
6
+ # router auth that install.sh added; leaves the rest of settings.json
7
+ # untouched. Pass --codex to strip the managed [model_providers.weave]
8
+ # block (and the matching top-level `model_provider`) from Codex's
9
+ # config.toml — anything outside the markers is preserved.
7
10
  #
8
11
  # Usage:
9
- # npx @workweave/router --uninstall # user scope
12
+ # npx @workweave/router --uninstall # Claude Code, user scope
13
+ # npx @workweave/router --uninstall --codex # Codex, user scope
10
14
  # npx @workweave/router --uninstall --scope project # run inside the repo
11
15
  # npx @workweave/router --uninstall --dir /tmp/test # --dir alone (user scope, .weave/)
12
16
  # npx @workweave/router --uninstall --scope project --dir /tmp # --dir + project scope (.claude/)
@@ -16,6 +20,7 @@ set -euo pipefail
16
20
  scope="user"
17
21
  scope_explicit="false"
18
22
  install_dir=""
23
+ target="claude"
19
24
 
20
25
  err() { printf "\033[31merror:\033[0m %s\n" "$*" >&2; }
21
26
  info() { printf "\033[36m==>\033[0m %s\n" "$*"; }
@@ -44,6 +49,15 @@ while [ $# -gt 0 ]; do
44
49
  install_dir="${2:-}"; shift 2
45
50
  [ -n "$install_dir" ] || { err "--dir requires a path"; exit 2; }
46
51
  ;;
52
+ --codex)
53
+ target="codex"; shift
54
+ ;;
55
+ --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.
59
+ target="claude"; shift
60
+ ;;
47
61
  -h|--help)
48
62
  # awk avoids GNU `head -n -<N>` (rejected by BSD head on macOS).
49
63
  awk 'NR<2 { next } /^set -euo/ { exit } { sub(/^# ?/, ""); print }' "$0"
@@ -55,11 +69,103 @@ while [ $# -gt 0 ]; do
55
69
  esac
56
70
  done
57
71
 
58
- if ! command -v jq >/dev/null 2>&1; then
59
- err "jq is required."
72
+ if [ "$target" = "claude" ] && ! command -v jq >/dev/null 2>&1; then
73
+ err "jq is required for the Claude Code uninstall path."
60
74
  exit 1
61
75
  fi
62
76
 
77
+ # Markers must stay in sync with install.sh. Keep verbatim.
78
+ WEAVE_CODEX_BEGIN_MARKER="# >>> weave-router managed (do not edit between markers) >>>"
79
+ WEAVE_CODEX_END_MARKER="# <<< weave-router managed <<<"
80
+
81
+ # strip_codex_block rewrites config.toml without the managed block and any
82
+ # top-level `model_provider = "weave"` that lived outside the markers (which
83
+ # can happen if the user copy-pasted our key into their own config). Other
84
+ # top-level model_provider values are preserved so we don't yank a user back
85
+ # into the OpenAI default when they meant to keep their own.
86
+ strip_codex_block() {
87
+ local config_file="$1"
88
+ local tmp; tmp="$(mktemp -t weave-codex-uninstall.XXXXXX)"
89
+ awk -v begin="$WEAVE_CODEX_BEGIN_MARKER" -v end="$WEAVE_CODEX_END_MARKER" '
90
+ $0 == begin { skip = 1; next }
91
+ $0 == end { skip = 0; next }
92
+ skip { next }
93
+ /^[[:space:]]*\[/ { in_section = 1 }
94
+ !in_section && /^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"weave"[[:space:]]*$/ { next }
95
+ { print }
96
+ ' "$config_file" >"$tmp"
97
+ mv "$tmp" "$config_file"
98
+ }
99
+
100
+ # ---------- codex uninstall path ----------
101
+
102
+ if [ "$target" = "codex" ]; then
103
+ # Resolve codex_config_file based on scope/dir. Mirrors the install path so
104
+ # an `install --codex --scope project` is exactly reversed by an
105
+ # `uninstall --codex --scope project`.
106
+ if [ -n "$install_dir" ]; then
107
+ install_dir="$(cd "$install_dir" 2>/dev/null && pwd || echo "$install_dir")"
108
+ codex_dir="$install_dir/.codex"
109
+ refuse_if_symlink "$codex_dir"
110
+ elif [ "$scope" = "user" ]; then
111
+ codex_dir="$HOME/.codex"
112
+ else
113
+ # Project scope: same prompt + git-root fallback as install.sh.
114
+ project_dir=""
115
+ if [ "$scope_explicit" = "false" ] && [ -r /dev/tty ]; then
116
+ default_project_dir="$(pwd)"
117
+ printf "Project directory to uninstall from [default: %s]: " "$default_project_dir"
118
+ read -r project_dir_choice </dev/tty || project_dir_choice=""
119
+ project_dir="${project_dir_choice:-$default_project_dir}"
120
+ case "$project_dir" in
121
+ "~") project_dir="$HOME" ;;
122
+ "~/"*) project_dir="$HOME/${project_dir#~/}" ;;
123
+ esac
124
+ if [ ! -d "$project_dir" ]; then
125
+ err "directory does not exist: $project_dir"
126
+ exit 1
127
+ fi
128
+ project_dir="$(cd "$project_dir" && pwd)"
129
+ fi
130
+ if [ -n "${project_dir:-}" ]; then
131
+ codex_dir="$project_dir/.codex"
132
+ else
133
+ if ! git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
134
+ err "--scope project must be run inside a git repo, or use --dir <path>."
135
+ exit 1
136
+ fi
137
+ codex_dir="$git_root/.codex"
138
+ fi
139
+ refuse_if_symlink "$codex_dir"
140
+ fi
141
+ codex_config_file="$codex_dir/config.toml"
142
+ refuse_if_symlink "$codex_config_file"
143
+
144
+ if [ -f "$codex_config_file" ]; then
145
+ strip_codex_block "$codex_config_file"
146
+ # If the file now contains only whitespace/comments, leave it: the user
147
+ # may have other comments worth keeping. Truly empty files we delete so
148
+ # we don't leave a zero-byte artifact behind.
149
+ if [ ! -s "$codex_config_file" ]; then
150
+ rm -f "$codex_config_file"
151
+ ok "Removed empty $codex_config_file"
152
+ else
153
+ ok "Cleaned $codex_config_file"
154
+ fi
155
+ else
156
+ info "No Codex config at $codex_config_file (already uninstalled?)"
157
+ fi
158
+
159
+ if [ -n "$install_dir" ]; then
160
+ ok "Weave Router uninstalled from $install_dir (Codex)."
161
+ else
162
+ ok "Weave Router uninstalled (Codex, scope=$scope)."
163
+ fi
164
+ exit 0
165
+ fi
166
+
167
+ # ---------- claude uninstall path ----------
168
+
63
169
  # Resolve the base directory. When --dir is given, use it directly.
64
170
  if [ -n "$install_dir" ]; then
65
171
  install_dir="$(cd "$install_dir" 2>/dev/null && pwd || echo "$install_dir")"