@workweave/router 0.1.3 → 0.1.5

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,6 +133,7 @@ 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
138
  "deepseek/deepseek-v4-pro": 0.00174,
138
139
  "gemini-2.0-flash": 0.0001,
@@ -144,6 +145,7 @@ prices='{
144
145
  "gemini-3-pro-preview": 0.002,
145
146
  "gemini-3.1-flash-lite-preview": 0.0001,
146
147
  "gemini-3.1-pro-preview": 0.002,
148
+ "gemini-3.5-flash": 0.0015,
147
149
  "gpt-4.1": 0.002,
148
150
  "gpt-4.1-mini": 0.0004,
149
151
  "gpt-4.1-nano": 0.0001,
@@ -161,16 +163,23 @@ prices='{
161
163
  "gpt-5.5-mini": 0.0005,
162
164
  "gpt-5.5-nano": 0.00015,
163
165
  "gpt-5.5-pro": 0.03,
166
+ "minimax/minimax-m2.7": 0.0003,
164
167
  "moonshotai/kimi-k2.5": 0.0006,
168
+ "moonshotai/kimi-k2.6": 0.00095,
165
169
  "qwen/qwen3-235b-a22b-2507": 0.000071,
166
170
  "qwen/qwen3-coder-next": 0.0005,
167
- "qwen/qwen3-next-80b-a3b-instruct": 0.00015
171
+ "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
172
+ "qwen/qwen3.6-35b-a3b": 0.00015,
173
+ "xiaomi/mimo-v2.5": 0.0004,
174
+ "xiaomi/mimo-v2.5-pro": 0.001,
175
+ "z-ai/glm-5": 0.0006
168
176
  },
169
177
  "output": {
170
178
  "claude-haiku-4-5": 0.004,
171
179
  "claude-opus-4-6": 0.075,
172
180
  "claude-opus-4-7": 0.075,
173
181
  "claude-sonnet-4-5": 0.015,
182
+ "claude-sonnet-4-6": 0.015,
174
183
  "deepseek/deepseek-v4-flash": 0.00028,
175
184
  "deepseek/deepseek-v4-pro": 0.00348,
176
185
  "gemini-2.0-flash": 0.0004,
@@ -182,6 +191,7 @@ prices='{
182
191
  "gemini-3-pro-preview": 0.008,
183
192
  "gemini-3.1-flash-lite-preview": 0.0004,
184
193
  "gemini-3.1-pro-preview": 0.008,
194
+ "gemini-3.5-flash": 0.009,
185
195
  "gpt-4.1": 0.008,
186
196
  "gpt-4.1-mini": 0.0016,
187
197
  "gpt-4.1-nano": 0.0004,
@@ -199,10 +209,16 @@ prices='{
199
209
  "gpt-5.5-mini": 0.0025,
200
210
  "gpt-5.5-nano": 0.0006,
201
211
  "gpt-5.5-pro": 0.12,
212
+ "minimax/minimax-m2.7": 0.0012,
202
213
  "moonshotai/kimi-k2.5": 0.003,
214
+ "moonshotai/kimi-k2.6": 0.004,
203
215
  "qwen/qwen3-235b-a22b-2507": 0.000463,
204
216
  "qwen/qwen3-coder-next": 0.0012,
205
- "qwen/qwen3-next-80b-a3b-instruct": 0.0012
217
+ "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
218
+ "qwen/qwen3.6-35b-a3b": 0.00095,
219
+ "xiaomi/mimo-v2.5": 0.002,
220
+ "xiaomi/mimo-v2.5-pro": 0.003,
221
+ "z-ai/glm-5": 0.00208
206
222
  }
207
223
  }'
208
224
  # END_GENERATED_PRICES
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: Pin this session to a specific model via the Weave Router
3
+ argument-hint: <model-id>
4
+ ---
5
+
6
+ /force-model $ARGUMENTS
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Clear an active /force-model pin and resume automatic routing
3
+ ---
4
+
5
+ /unforce-model
package/install.sh CHANGED
@@ -1,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,12 @@ 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
+
210
238
  # ---------- identity helpers ----------
211
239
  #
212
240
  # The router parses X-Weave-User-Email and X-Weave-User-Name on every protocol
@@ -269,7 +297,7 @@ normalize_name() {
269
297
 
270
298
  # resolve_user_email picks the email to plant in router request headers so the
271
299
  # router can attribute traffic to a person even on shared API keys. Priority:
272
- # WEAVE_USER_EMAIL env override -> git config user.email -> interactive prompt
300
+ # WEAVE_USER_EMAIL env override git config user.email interactive prompt
273
301
  # (pre-filled with whatever we found). In --non-interactive mode we never
274
302
  # prompt, so unset/invalid means we ship no header (router treats that as
275
303
  # account_uuid-only, same as today). Echoes the validated email on stdout.
@@ -290,6 +318,10 @@ resolve_user_email() {
290
318
  printf '%s' "$candidate"
291
319
  return
292
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.
293
325
  if [ "$quiet" = "true" ]; then
294
326
  printf '%s' "$candidate"
295
327
  return
@@ -318,8 +350,131 @@ resolve_user_email() {
318
350
  esac
319
351
  }
320
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
+ # Tag the client so telemetry can attribute traffic to Codex vs other CLIs
399
+ # that share the same router key. The router otherwise has to guess from
400
+ # User-Agent.
401
+ headers_parts="${headers_parts}, \"X-App\" = \"codex\""
402
+ local headers_line="http_headers = { ${headers_parts} }"
403
+
404
+ local block
405
+ block="$(cat <<TOML
406
+ ${WEAVE_CODEX_BEGIN_MARKER}
407
+ # Managed by the Weave Router installer. Re-running the installer rewrites
408
+ # this block; \`./uninstall.sh --codex\` removes it. To opt out without
409
+ # uninstalling, change the model_provider value below.
410
+ model_provider = "weave"
411
+
412
+ [model_providers.weave]
413
+ name = "Weave Router"
414
+ base_url = "${esc_url}/v1"
415
+ wire_api = "responses"
416
+ ${headers_line}
417
+ ${WEAVE_CODEX_END_MARKER}
418
+ TOML
419
+ )"
420
+
421
+ if [ -f "$config_file" ]; then
422
+ local tmp; tmp="$(mktemp -t weave-codex.XXXXXX)"
423
+ # Strip the managed block (between markers) plus any top-level
424
+ # `model_provider =` outside it. We define "top-level" as everything
425
+ # before the first `[section]` header. The awk handles both passes in
426
+ # one sweep so we never emit a duplicate.
427
+ awk -v begin="$WEAVE_CODEX_BEGIN_MARKER" -v end="$WEAVE_CODEX_END_MARKER" '
428
+ $0 == begin { skip = 1; next }
429
+ $0 == end { skip = 0; next }
430
+ skip { next }
431
+ /^[[:space:]]*\[/ { in_section = 1 }
432
+ !in_section && /^[[:space:]]*model_provider[[:space:]]*=/ { next }
433
+ { print }
434
+ ' "$config_file" >"$tmp"
435
+
436
+ # Insert the managed block at TOML top-level scope, NOT end-of-file. In
437
+ # TOML, every bare key after a `[section]` header belongs to that
438
+ # section, so appending `model_provider = "weave"` after a user's
439
+ # existing `[profiles.foo]` would silently scope it as
440
+ # `profiles.foo.model_provider` — Codex would never see the top-level
441
+ # default and routing would silently fail to activate. We splice the
442
+ # block in just before the first user section header so:
443
+ # <user's top-level keys> ← still top-level
444
+ # <our managed block> ← model_provider stays top-level
445
+ # [model_providers.weave] ← scoped section, OK anywhere
446
+ # <user's sections> ← re-scope, unaffected
447
+ local first_section
448
+ first_section="$(awk '/^[[:space:]]*\[/ { print NR; exit }' "$tmp")"
449
+ if [ -n "$first_section" ]; then
450
+ # BSD `head -n 0` (macOS default) errors with "illegal line count"
451
+ # and trips `set -euo pipefail`, leaving an empty config. Skip the
452
+ # head call entirely when the file starts with a section header.
453
+ {
454
+ if [ "$first_section" -gt 1 ]; then
455
+ head -n "$((first_section - 1))" "$tmp"
456
+ fi
457
+ printf "%s\n" "$block"
458
+ tail -n "+${first_section}" "$tmp"
459
+ } >"$config_file"
460
+ else
461
+ # No section headers in the existing file — every prior user key was
462
+ # already at top-level. Our block ends with its own [section], so
463
+ # appending is safe (no bare keys follow).
464
+ cp "$tmp" "$config_file"
465
+ printf "\n%s\n" "$block" >>"$config_file"
466
+ fi
467
+ rm -f "$tmp"
468
+ else
469
+ printf "%s\n" "$block" >"$config_file"
470
+ fi
471
+ # 0600: the file holds a router key. Even at user scope, mode 644 would
472
+ # leak the key to any local user on a shared box.
473
+ chmod 600 "$config_file"
474
+ }
475
+
321
476
  # resolve_user_name mirrors resolve_user_email but for display name. Priority:
322
- # WEAVE_USER_NAME env override -> git config user.name -> empty. We don't
477
+ # WEAVE_USER_NAME env override git config user.name empty. We don't
323
478
  # prompt for name independently: if email prompting yielded nothing, name
324
479
  # almost certainly will too, and a second prompt is noise. Echoes the
325
480
  # validated name on stdout.
@@ -417,6 +572,14 @@ while [ $# -gt 0 ]; do
417
572
  install_dir="${2:-}"; shift 2
418
573
  [ -n "$install_dir" ] || { err "--dir requires a path"; exit 2; }
419
574
  ;;
575
+ --codex)
576
+ target="codex"; target_explicit="true"; shift
577
+ ;;
578
+ --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.
581
+ target="claude"; target_explicit="true"; shift
582
+ ;;
420
583
  -h|--help)
421
584
  usage 0
422
585
  ;;
@@ -432,9 +595,31 @@ fi
432
595
  # trim trailing slash for cleanliness
433
596
  base_url="${base_url%/}"
434
597
 
598
+ # ---------- interactive target prompt ----------
599
+
600
+ # If neither --claude nor --codex was passed and we have a controlling
601
+ # terminal, ask which tool to install for. Non-interactive runs (CI,
602
+ # `curl | sh --non-interactive`) silently use the "claude" default — same
603
+ # behavior the script had before --codex existed, so existing pipelines
604
+ # don't change semantics. We prompt BEFORE print_banner so the banner's
605
+ # target label (Claude Code installer / Codex installer) reflects the choice.
606
+ if [ "$target_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r /dev/tty ]; then
607
+ printf "%sInstall target:%s\n" "$C_BOLD" "$C_RESET"
608
+ printf " %s1)%s Claude Code %s— patches ~/.claude/settings.json (or <repo>/.claude/)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
609
+ 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"
611
+ read -r target_choice </dev/tty || target_choice=""
612
+ case "${target_choice:-1}" in
613
+ 1|""|claude|c|C) target="claude" ;;
614
+ 2|codex|x|X) target="codex" ;;
615
+ *) err "invalid choice: $target_choice"; exit 2 ;;
616
+ esac
617
+ fi
618
+
435
619
  # Banner runs before the interactive scope prompt so the very first thing
436
620
  # users see when `make full-setup` hands off to install.sh is the wordmark,
437
- # not a bare "Install scope:" line.
621
+ # not a bare "Install scope:" line. Target prompt above already finalized
622
+ # $target, so the banner's per-target label reflects the user's choice.
438
623
  print_banner
439
624
 
440
625
  # ---------- interactive scope prompt ----------
@@ -443,9 +628,22 @@ print_banner
443
628
  # scope to install into. Non-interactive runs (CI, `curl | sh --non-interactive`)
444
629
  # silently use the "user" default.
445
630
  if [ -z "$install_dir" ] && [ "$scope_explicit" = "false" ] && [ "$non_interactive" = "false" ] && [ -r /dev/tty ]; then
631
+ # Per-target paths so the prompt text matches what actually gets written.
632
+ case "$target" in
633
+ codex)
634
+ scope_user_path="~/.codex/"
635
+ scope_project_path="<repo>/.codex/"
636
+ scope_cli_label="codex"
637
+ ;;
638
+ *)
639
+ scope_user_path="~/.claude/"
640
+ scope_project_path="<repo>/.claude/"
641
+ scope_cli_label="claude"
642
+ ;;
643
+ esac
446
644
  printf "%sInstall scope:%s\n" "$C_BOLD" "$C_RESET"
447
- printf " %s1)%s user %s— write to ~/.claude/ (applies everywhere you run claude)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
448
- printf " %s2)%s project %s— write to <repo>/.claude/ (applies only inside this repo)%s\n" "$C_BRAND" "$C_RESET" "$C_DIM" "$C_RESET"
645
+ 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"
646
+ 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"
449
647
  printf "Choose %s[1/2]%s (default %s1%s): " "$C_BOLD" "$C_RESET" "$C_BOLD" "$C_RESET"
450
648
  read -r scope_choice </dev/tty || scope_choice=""
451
649
  case "${scope_choice:-1}" in
@@ -477,15 +675,29 @@ fi
477
675
 
478
676
  # ---------- pre-flight ----------
479
677
 
480
- [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
678
+ [ "$quiet" = "true" ] || info "scope=${C_BOLD}${scope}${C_RESET} target=${C_BOLD}${target}${C_RESET} base_url=${C_BOLD}${base_url}${C_RESET}"
481
679
 
482
- require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
680
+ # 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
683
+ require_cmd jq "macOS: 'brew install jq' · Debian/Ubuntu: 'sudo apt install jq'"
684
+ fi
483
685
  require_cmd curl "macOS/Linux: usually preinstalled — check your package manager"
484
686
 
485
- if ! command -v claude >/dev/null 2>&1; then
486
- warn "'claude' not found on PATH. Install Claude Code from https://claude.com/code, then re-run this script."
487
- warn "Continuing settings.json will be written and will take effect once Claude Code is installed."
488
- fi
687
+ case "$target" in
688
+ claude)
689
+ if ! command -v claude >/dev/null 2>&1; then
690
+ warn "'claude' not found on PATH. Install Claude Code from https://claude.com/code, then re-run this script."
691
+ warn "Continuing — settings.json will be written and will take effect once Claude Code is installed."
692
+ fi
693
+ ;;
694
+ codex)
695
+ if ! command -v codex >/dev/null 2>&1; then
696
+ warn "'codex' not found on PATH. Install via 'npm install -g @openai/codex' (or brew install codex), then re-run this script."
697
+ warn "Continuing — config.toml will be written and will take effect once Codex is installed."
698
+ fi
699
+ ;;
700
+ esac
489
701
 
490
702
  script_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
491
703
 
@@ -518,42 +730,59 @@ else
518
730
  esac
519
731
  fi
520
732
 
521
- case "$scope" in
522
- user)
523
- settings_dir="$settings_base/.claude"
524
- settings_file="$settings_dir/settings.json"
525
- local_settings_file=""
526
- statusline_dir="${settings_base}/.weave"
527
- statusline_file="$statusline_dir/cc-statusline.sh"
528
- statusline_path_for_settings="$statusline_file"
529
- ;;
530
- project)
531
- settings_dir="$settings_base/.claude"
532
- settings_file="$settings_dir/settings.json"
533
- local_settings_file="$settings_dir/settings.local.json"
534
- statusline_dir="$settings_base/.claude"
535
- statusline_file="$statusline_dir/cc-statusline.sh"
536
- # Portable relative path for real repos (teammates can clone anywhere).
537
- # Absolute path when --dir overrides (no meaningful $CLAUDE_PROJECT_DIR).
538
- if [ -z "$install_dir" ]; then
539
- statusline_path_for_settings="\${CLAUDE_PROJECT_DIR}/.claude/cc-statusline.sh"
540
- else
733
+ if [ "$target" = "claude" ]; then
734
+ case "$scope" in
735
+ user)
736
+ settings_dir="$settings_base/.claude"
737
+ settings_file="$settings_dir/settings.json"
738
+ local_settings_file=""
739
+ statusline_dir="${settings_base}/.weave"
740
+ statusline_file="$statusline_dir/cc-statusline.sh"
541
741
  statusline_path_for_settings="$statusline_file"
542
- fi
543
- ;;
544
- esac
742
+ ;;
743
+ project)
744
+ settings_dir="$settings_base/.claude"
745
+ settings_file="$settings_dir/settings.json"
746
+ local_settings_file="$settings_dir/settings.local.json"
747
+ statusline_dir="$settings_base/.claude"
748
+ statusline_file="$statusline_dir/cc-statusline.sh"
749
+ # Portable relative path for real repos (teammates can clone anywhere).
750
+ # Absolute path when --dir overrides (no meaningful $CLAUDE_PROJECT_DIR).
751
+ if [ -z "$install_dir" ]; then
752
+ statusline_path_for_settings="\${CLAUDE_PROJECT_DIR}/.claude/cc-statusline.sh"
753
+ else
754
+ statusline_path_for_settings="$statusline_file"
755
+ fi
756
+ ;;
757
+ esac
545
758
 
546
- # Symlink containment: refuse if any target path is a symlink. User-scope paths
547
- # under $HOME are trusted; project-scope and --dir paths come from a git repo or
548
- # user-supplied directory that may be hostile, so we check those.
549
- if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
550
- refuse_if_symlink "$settings_dir"
551
- refuse_if_symlink "$settings_file"
552
- refuse_if_symlink "$local_settings_file"
553
- refuse_if_symlink "$statusline_file"
554
- fi
759
+ # Symlink containment: refuse if any target path is a symlink. User-scope
760
+ # paths under $HOME are trusted; project-scope and --dir paths come from a
761
+ # git repo or user-supplied directory that may be hostile, so we check those.
762
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
763
+ refuse_if_symlink "$settings_dir"
764
+ refuse_if_symlink "$settings_file"
765
+ refuse_if_symlink "$local_settings_file"
766
+ refuse_if_symlink "$statusline_file"
767
+ fi
768
+
769
+ mkdir -p "$settings_dir" "$statusline_dir"
770
+ else
771
+ # Codex CLI reads config from ~/.codex/config.toml by default. For project
772
+ # scope we write to <repo>/.codex/config.toml; the user invokes Codex with
773
+ # `CODEX_HOME=<repo>/.codex codex` (or runs from the repo if Codex auto-
774
+ # discovers). The router key is embedded in the file so it stays per-
775
+ # teammate — .codex/config.toml goes in .gitignore in project scope.
776
+ codex_dir="$settings_base/.codex"
777
+ codex_config_file="$codex_dir/config.toml"
778
+
779
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
780
+ refuse_if_symlink "$codex_dir"
781
+ refuse_if_symlink "$codex_config_file"
782
+ fi
555
783
 
556
- mkdir -p "$settings_dir" "$statusline_dir"
784
+ mkdir -p "$codex_dir"
785
+ fi
557
786
 
558
787
  # ---------- token handling ----------
559
788
 
@@ -587,17 +816,20 @@ if [ -n "${WEAVE_ROUTER_KEY:-}" ]; then
587
816
 
588
817
  # ---------- identity (user email + name) ----------
589
818
  #
590
- # Resolved after api_key so the prompt sequence reads naturally: key first,
591
- # then "who are you?". Both end up as request headers the router parses on
592
- # every protocol so customers can link router users to Weave accounts on the
593
- # dashboard. Empty values = no header (router stays in account_uuid-only
594
- # mode), so existing behavior is preserved when git config is unset.
819
+ # The router parses X-Weave-User-Email and X-Weave-User-Name on every protocol
820
+ # (Anthropic, OpenAI/Codex, Gemini) and persists them onto
821
+ # router.model_router_users so customers can attribute traffic to a person even
822
+ # when many people share one API key. We plant the headers at install time
823
+ # because Claude Code's metadata.user_id payload carries only account_uuid (no
824
+ # email), and Codex carries no identity at all — without this step the router
825
+ # only ever sees anonymous UUIDs for non-OTLP customers.
826
+ #
827
+ # Gate name on email: when the user explicitly opts out of email identity (via
828
+ # '-' at the prompt or by clearing git config), don't auto-plant a name from
829
+ # git config either. Opt-out should be all-or-nothing so the router
830
+ # consistently sees zero identity headers when the user wants to stay
831
+ # anonymous.
595
832
  user_email="$(resolve_user_email)"
596
- # Gate name on email: when the user explicitly opts out of email identity
597
- # (via '-' at the prompt or by clearing git config), don't auto-plant a
598
- # name from git config either. Opt-out should be all-or-nothing so the
599
- # router consistently sees zero identity headers when the user wants to
600
- # stay anonymous.
601
833
  if [ -n "$user_email" ]; then
602
834
  user_name="$(resolve_user_name)"
603
835
  else
@@ -611,6 +843,106 @@ else
611
843
  info "No identity set — router traffic will be attributed by account UUID only."
612
844
  fi
613
845
 
846
+ # ---------- slash command wrappers (shared by both targets) ----------
847
+ #
848
+ # Claude Code intercepts any prompt starting with "/" as a local slash command,
849
+ # so a typed /force-model would resolve to "Unknown command" and never reach
850
+ # the router. Codex CLI does the same (its built-in / menu has its own set).
851
+ # Drop wrapper markdown files into the per-target commands directory so the
852
+ # slash invocation expands locally into a literal "/force-model …" prompt that
853
+ # the router's first-line parser picks up.
854
+ #
855
+ # Layout:
856
+ # Claude: <settings_dir>/commands/{force-model,unforce-model}.md → /force-model
857
+ # Codex: <codex_dir>/prompts/{force-model,unforce-model}.md → /prompts:force-model
858
+ #
859
+ # Files come from install/commands/ in the repo (or the colocated commands/
860
+ # directory the npm package ships alongside install.sh).
861
+ install_slash_commands() {
862
+ dst_dir="$1"
863
+ commands_src_dir=""
864
+ for candidate in \
865
+ "$script_dir/commands" \
866
+ "$script_dir/../commands"
867
+ do
868
+ if [ -d "$candidate" ]; then
869
+ commands_src_dir="$candidate"
870
+ break
871
+ fi
872
+ done
873
+ [ -n "$commands_src_dir" ] || return 0
874
+
875
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
876
+ refuse_if_symlink "$dst_dir"
877
+ fi
878
+ mkdir -p "$dst_dir"
879
+ for cmd in force-model unforce-model; do
880
+ src="$commands_src_dir/$cmd.md"
881
+ dst="$dst_dir/$cmd.md"
882
+ [ -f "$src" ] || continue
883
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
884
+ refuse_if_symlink "$dst"
885
+ fi
886
+ cp "$src" "$dst"
887
+ done
888
+ ok "Slash commands written to $dst_dir (force-model, unforce-model)"
889
+ }
890
+
891
+ # ---------- codex install path (dispatch + exit before the Claude-only writes) ----------
892
+
893
+ if [ "$target" = "codex" ]; then
894
+ write_codex_config "$codex_config_file" "$base_url" "$api_key" "$user_email" "$user_name"
895
+ ok "Codex config written to $codex_config_file"
896
+ install_slash_commands "$codex_dir/prompts"
897
+
898
+ # Project scope: ensure the per-teammate config (which holds the router key)
899
+ # is gitignored. The base URL is the same for every teammate, so a
900
+ # committed shared file would still leak the per-key portion. Easier to
901
+ # ignore the whole config and have each teammate run the installer.
902
+ if [ "$scope" = "project" ] && [ -z "$install_dir" ] && [ -n "${git_root:-}" ]; then
903
+ gitignore="$git_root/.gitignore"
904
+ refuse_if_symlink "$gitignore"
905
+ for entry in \
906
+ ".codex/config.toml"
907
+ do
908
+ if [ ! -f "$gitignore" ] || ! grep -qxF "$entry" "$gitignore"; then
909
+ printf '%s\n' "$entry" >>"$gitignore"
910
+ fi
911
+ done
912
+ ok "Updated $gitignore (ignored .codex/config.toml)"
913
+ fi
914
+
915
+ # Post-install verification: same probes the Claude path runs so a working
916
+ # install gives the same green checks regardless of target.
917
+ if [ "$quiet" != "true" ]; then
918
+ if ! spin "Pinging $base_url/health" curl -fsS --max-time 5 "$base_url/health"; then
919
+ warn "Could not reach $base_url/health within 5s. Settings are written; verify the router is running."
920
+ fi
921
+ fi
922
+
923
+ if [ -n "$api_key" ]; then
924
+ # Pass the router key via stdin (`@-`) instead of -H so it never lands in
925
+ # the process arg list. Mirrors the Claude-path validate logic.
926
+ validate_codex_key() {
927
+ printf '%s: %s\n' "$router_key_header" "$api_key" \
928
+ | curl -fsS --max-time 5 --header @- "$base_url/validate"
929
+ }
930
+ if ! spin "Validating API key" validate_codex_key; then
931
+ warn "Router rejected the API key (check it matches the dashboard at $base_url/ui/)."
932
+ fi
933
+ fi
934
+
935
+ printf "\n"
936
+ printf "%s✓%s %s%sWeave Router installed for Codex.%s\n" \
937
+ "$C_GREEN" "$C_RESET" "$C_BOLD" "$C_BRAND" "$C_RESET"
938
+ if [ "$scope" = "project" ] || [ -n "$install_dir" ]; then
939
+ # Codex auto-discovers ~/.codex; for non-user installs the caller has to
940
+ # point CODEX_HOME at the directory we wrote so Codex finds our config.
941
+ info "Run Codex with CODEX_HOME=$codex_dir codex so it picks up this config."
942
+ fi
943
+ exit 0
944
+ fi
945
+
614
946
  # ---------- write the statusline script ----------
615
947
 
616
948
  cat > "$statusline_file" << 'STATUSLINE_EOF'
@@ -749,6 +1081,7 @@ prices='{
749
1081
  "claude-opus-4-6": 0.015,
750
1082
  "claude-opus-4-7": 0.015,
751
1083
  "claude-sonnet-4-5": 0.003,
1084
+ "claude-sonnet-4-6": 0.003,
752
1085
  "deepseek/deepseek-v4-flash": 0.00014,
753
1086
  "deepseek/deepseek-v4-pro": 0.00174,
754
1087
  "gemini-2.0-flash": 0.0001,
@@ -760,6 +1093,7 @@ prices='{
760
1093
  "gemini-3-pro-preview": 0.002,
761
1094
  "gemini-3.1-flash-lite-preview": 0.0001,
762
1095
  "gemini-3.1-pro-preview": 0.002,
1096
+ "gemini-3.5-flash": 0.0015,
763
1097
  "gpt-4.1": 0.002,
764
1098
  "gpt-4.1-mini": 0.0004,
765
1099
  "gpt-4.1-nano": 0.0001,
@@ -777,16 +1111,23 @@ prices='{
777
1111
  "gpt-5.5-mini": 0.0005,
778
1112
  "gpt-5.5-nano": 0.00015,
779
1113
  "gpt-5.5-pro": 0.03,
1114
+ "minimax/minimax-m2.7": 0.0003,
780
1115
  "moonshotai/kimi-k2.5": 0.0006,
1116
+ "moonshotai/kimi-k2.6": 0.00095,
781
1117
  "qwen/qwen3-235b-a22b-2507": 0.000071,
782
1118
  "qwen/qwen3-coder-next": 0.0005,
783
- "qwen/qwen3-next-80b-a3b-instruct": 0.00015
1119
+ "qwen/qwen3-next-80b-a3b-instruct": 0.00015,
1120
+ "qwen/qwen3.6-35b-a3b": 0.00015,
1121
+ "xiaomi/mimo-v2.5": 0.0004,
1122
+ "xiaomi/mimo-v2.5-pro": 0.001,
1123
+ "z-ai/glm-5": 0.0006
784
1124
  },
785
1125
  "output": {
786
1126
  "claude-haiku-4-5": 0.004,
787
1127
  "claude-opus-4-6": 0.075,
788
1128
  "claude-opus-4-7": 0.075,
789
1129
  "claude-sonnet-4-5": 0.015,
1130
+ "claude-sonnet-4-6": 0.015,
790
1131
  "deepseek/deepseek-v4-flash": 0.00028,
791
1132
  "deepseek/deepseek-v4-pro": 0.00348,
792
1133
  "gemini-2.0-flash": 0.0004,
@@ -798,6 +1139,7 @@ prices='{
798
1139
  "gemini-3-pro-preview": 0.008,
799
1140
  "gemini-3.1-flash-lite-preview": 0.0004,
800
1141
  "gemini-3.1-pro-preview": 0.008,
1142
+ "gemini-3.5-flash": 0.009,
801
1143
  "gpt-4.1": 0.008,
802
1144
  "gpt-4.1-mini": 0.0016,
803
1145
  "gpt-4.1-nano": 0.0004,
@@ -815,10 +1157,16 @@ prices='{
815
1157
  "gpt-5.5-mini": 0.0025,
816
1158
  "gpt-5.5-nano": 0.0006,
817
1159
  "gpt-5.5-pro": 0.12,
1160
+ "minimax/minimax-m2.7": 0.0012,
818
1161
  "moonshotai/kimi-k2.5": 0.003,
1162
+ "moonshotai/kimi-k2.6": 0.004,
819
1163
  "qwen/qwen3-235b-a22b-2507": 0.000463,
820
1164
  "qwen/qwen3-coder-next": 0.0012,
821
- "qwen/qwen3-next-80b-a3b-instruct": 0.0012
1165
+ "qwen/qwen3-next-80b-a3b-instruct": 0.0012,
1166
+ "qwen/qwen3.6-35b-a3b": 0.00095,
1167
+ "xiaomi/mimo-v2.5": 0.002,
1168
+ "xiaomi/mimo-v2.5-pro": 0.003,
1169
+ "z-ai/glm-5": 0.00208
822
1170
  }
823
1171
  }'
824
1172
  # END_GENERATED_PRICES
@@ -1001,6 +1349,7 @@ fi
1001
1349
  if [ -n "$user_name" ]; then
1002
1350
  custom_headers="$custom_headers"$'\n'"X-Weave-User-Name: $user_name"
1003
1351
  fi
1352
+ custom_headers="$custom_headers"$'\n'"X-App: claude-code"
1004
1353
 
1005
1354
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1006
1355
  jq -n --arg url "$base_url" --arg sl "$statusline_path_for_settings" '{
@@ -1033,6 +1382,9 @@ else
1033
1382
  fi
1034
1383
  ok "Settings written to $settings_file"
1035
1384
 
1385
+ # Slash command wrappers — see install_slash_commands() below for the why.
1386
+ install_slash_commands "$settings_dir/commands"
1387
+
1036
1388
  if [ "$scope" = "project" ] && [ -z "$install_dir" ]; then
1037
1389
  jq -n --arg header "$custom_headers" '{
1038
1390
  env: { ANTHROPIC_CUSTOM_HEADERS: $header }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "One-command installer that points Claude Code at the Weave Router.",
5
5
  "bin": {
6
6
  "weave-router": "bin.js"
@@ -10,6 +10,7 @@
10
10
  "install.sh",
11
11
  "uninstall.sh",
12
12
  "cc-statusline.sh",
13
+ "commands/",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],
package/uninstall.sh CHANGED
@@ -1,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,119 @@ 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
+ # Remove only the prompt files this installer owns; leave any user-authored
160
+ # entries in prompts/ alone. The dir itself is dropped only if empty after.
161
+ codex_prompts_dir="$codex_dir/prompts"
162
+ if [ -d "$codex_prompts_dir" ]; then
163
+ refuse_if_symlink "$codex_prompts_dir"
164
+ for cmd in force-model unforce-model; do
165
+ cmd_file="$codex_prompts_dir/$cmd.md"
166
+ if [ -f "$cmd_file" ]; then
167
+ refuse_if_symlink "$cmd_file"
168
+ rm -f "$cmd_file"
169
+ ok "Removed $cmd_file"
170
+ fi
171
+ done
172
+ rmdir "$codex_prompts_dir" 2>/dev/null || true
173
+ fi
174
+
175
+ if [ -n "$install_dir" ]; then
176
+ ok "Weave Router uninstalled from $install_dir (Codex)."
177
+ else
178
+ ok "Weave Router uninstalled (Codex, scope=$scope)."
179
+ fi
180
+ exit 0
181
+ fi
182
+
183
+ # ---------- claude uninstall path ----------
184
+
63
185
  # Resolve the base directory. When --dir is given, use it directly.
64
186
  if [ -n "$install_dir" ]; then
65
187
  install_dir="$(cd "$install_dir" 2>/dev/null && pwd || echo "$install_dir")"
@@ -163,6 +285,24 @@ for f in "$statusline_file"; do
163
285
  fi
164
286
  done
165
287
 
288
+ # Remove only the slash command files this installer owns; leave any other
289
+ # files in commands/ alone. The directory itself stays if it still contains
290
+ # unrelated user commands.
291
+ commands_dir="$(dirname "$settings_file")/commands"
292
+ if [ -d "$commands_dir" ]; then
293
+ refuse_if_symlink "$commands_dir"
294
+ for cmd in force-model unforce-model; do
295
+ cmd_file="$commands_dir/$cmd.md"
296
+ if [ -f "$cmd_file" ]; then
297
+ refuse_if_symlink "$cmd_file"
298
+ rm -f "$cmd_file"
299
+ ok "Removed $cmd_file"
300
+ fi
301
+ done
302
+ # Clean up the dir only if we left nothing behind.
303
+ rmdir "$commands_dir" 2>/dev/null || true
304
+ fi
305
+
166
306
  if [ -n "$install_dir" ]; then
167
307
  ok "Weave Router uninstalled from $install_dir."
168
308
  else