@workweave/router 0.2.2 → 0.2.3

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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * @workweave/router — use a caller's ChatGPT (Codex) subscription for their
3
+ * opencode turns, routed through the Weave Router.
4
+ *
5
+ * opencode removed built-in subscription auth in 1.3.0 and binds OAuth to its
6
+ * own first-party providers (the bundled `openai/codex.ts` plugin hardcodes the
7
+ * upstream to chatgpt.com and binds provider "openai"), so a custom router
8
+ * provider can't reuse it. This plugin re-implements the same ChatGPT OAuth +
9
+ * refresh against a CUSTOM provider id (`weave-codex`) and, crucially, leaves
10
+ * the request URL pointed at the Weave Router instead of chatgpt.com.
11
+ *
12
+ * Wire shape produced (matches the router's /v1/responses Codex passthrough):
13
+ * - POST {router}/v1/responses (Responses wire format)
14
+ * - Authorization: Bearer <ChatGPT JWT> (the caller's subscription)
15
+ * - ChatGPT-Account-Id: <account id> (paired, required by Codex backend)
16
+ * - X-Weave-Router-Key: rk_... (from config options.headers)
17
+ *
18
+ * The router authenticates off X-Weave-Router-Key (so Authorization is free for
19
+ * the subscription JWT), detects the inbound Codex bearer, and serves the turn
20
+ * on the caller's own plan at the subscription fee. The router key + identity
21
+ * headers (X-App, X-Weave-User-Email) live in opencode.json `options.headers`
22
+ * written by the installer; this plugin manages only the dynamic, refreshable
23
+ * subscription credential.
24
+ */
25
+
26
+ import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin"
27
+
28
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
29
+ // Overridable for self-hosted OpenAI auth proxies and for tests (mirrors the
30
+ // bundled codex plugin's `options.issuer`).
31
+ const ISSUER = process.env.WEAVE_CODEX_OAUTH_ISSUER ?? "https://auth.openai.com"
32
+ const OAUTH_PORT = 1455
33
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
34
+ // Provider id this plugin owns. Must match the provider block the installer
35
+ // writes into opencode.json. Deliberately NOT "openai" — that id is claimed by
36
+ // opencode's bundled codex plugin, which rewrites the upstream to chatgpt.com.
37
+ const PROVIDER_ID = "weave-codex"
38
+ // Placeholder so the @ai-sdk/openai provider considers auth configured; the
39
+ // loader's fetch overwrites Authorization with the real subscription bearer.
40
+ const DUMMY_KEY = "weave-router-oauth"
41
+ const USER_AGENT = "weave-router-opencode"
42
+
43
+ interface PkceCodes {
44
+ verifier: string
45
+ challenge: string
46
+ }
47
+
48
+ function base64UrlEncode(buffer: ArrayBuffer): string {
49
+ const bytes = new Uint8Array(buffer)
50
+ const binary = String.fromCharCode(...bytes)
51
+ return btoa(binary)
52
+ .replace(/\+/g, "-")
53
+ .replace(/\//g, "_")
54
+ .replace(/=+$/, "")
55
+ }
56
+
57
+ async function generatePKCE(): Promise<PkceCodes> {
58
+ // base64url of 32 random bytes → a 43-char verifier drawn uniformly from the
59
+ // PKCE unreserved set (RFC 7636 §4.1). Encoding the raw bytes avoids the
60
+ // modulo-on-a-CSPRNG bias that mapping bytes onto a 64-char alphabet would
61
+ // introduce (and that static analysis flags).
62
+ const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
63
+ const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
64
+ return { verifier, challenge }
65
+ }
66
+
67
+ interface IdTokenClaims {
68
+ chatgpt_account_id?: string
69
+ organizations?: Array<{ id: string }>
70
+ "https://api.openai.com/auth"?: { chatgpt_account_id?: string }
71
+ }
72
+
73
+ function parseJwtClaims(token: string): IdTokenClaims | undefined {
74
+ const parts = token.split(".")
75
+ if (parts.length !== 3) return undefined
76
+ try {
77
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString())
78
+ } catch {
79
+ return undefined
80
+ }
81
+ }
82
+
83
+ function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
84
+ return (
85
+ claims.chatgpt_account_id ||
86
+ claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
87
+ claims.organizations?.[0]?.id
88
+ )
89
+ }
90
+
91
+ function extractAccountId(tokens: TokenResponse): string | undefined {
92
+ if (tokens.id_token) {
93
+ const claims = parseJwtClaims(tokens.id_token)
94
+ const accountId = claims && extractAccountIdFromClaims(claims)
95
+ if (accountId) return accountId
96
+ }
97
+ if (tokens.access_token) {
98
+ const claims = parseJwtClaims(tokens.access_token)
99
+ return claims ? extractAccountIdFromClaims(claims) : undefined
100
+ }
101
+ return undefined
102
+ }
103
+
104
+ interface TokenResponse {
105
+ id_token: string
106
+ access_token: string
107
+ refresh_token: string
108
+ expires_in?: number
109
+ }
110
+
111
+ function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
112
+ const params = new URLSearchParams({
113
+ response_type: "code",
114
+ client_id: CLIENT_ID,
115
+ redirect_uri: redirectUri,
116
+ scope: "openid profile email offline_access",
117
+ code_challenge: pkce.challenge,
118
+ code_challenge_method: "S256",
119
+ id_token_add_organizations: "true",
120
+ codex_cli_simplified_flow: "true",
121
+ state,
122
+ originator: "codex_cli_ts",
123
+ })
124
+ return `${ISSUER}/oauth/authorize?${params.toString()}`
125
+ }
126
+
127
+ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
128
+ const response = await fetch(`${ISSUER}/oauth/token`, {
129
+ method: "POST",
130
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
131
+ body: new URLSearchParams({
132
+ grant_type: "authorization_code",
133
+ code,
134
+ redirect_uri: redirectUri,
135
+ client_id: CLIENT_ID,
136
+ code_verifier: pkce.verifier,
137
+ }).toString(),
138
+ })
139
+ if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`)
140
+ return response.json() as Promise<TokenResponse>
141
+ }
142
+
143
+ async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
144
+ const response = await fetch(`${ISSUER}/oauth/token`, {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
147
+ body: new URLSearchParams({
148
+ grant_type: "refresh_token",
149
+ refresh_token: refreshToken,
150
+ client_id: CLIENT_ID,
151
+ }).toString(),
152
+ })
153
+ if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`)
154
+ return response.json() as Promise<TokenResponse>
155
+ }
156
+
157
+ // ---- Browser OAuth loopback server (PKCE) --------------------------------
158
+
159
+ function escapeHtml(s: string): string {
160
+ return s.replace(/[&<>"']/g, (c) =>
161
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
162
+ )
163
+ }
164
+
165
+ const HTML_SUCCESS = `<!doctype html><html><head><title>Weave Router — Codex authorized</title></head>
166
+ <body style="font-family:system-ui;background:#131010;color:#f1ecec;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
167
+ <div style="text-align:center"><h1>Authorization successful</h1><p>You can close this window and return to opencode.</p>
168
+ <script>setTimeout(()=>window.close(),2000)</script></div></body></html>`
169
+
170
+ const renderOAuthError = (error: string) => `<!doctype html><html><head><title>Weave Router — Codex authorization failed</title></head>
171
+ <body style="font-family:system-ui;background:#131010;color:#f1ecec;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
172
+ <div style="text-align:center"><h1 style="color:#fc533a">Authorization failed</h1>
173
+ <div style="color:#ff917b;font-family:monospace;margin-top:1rem;padding:1rem;background:#3c140d;border-radius:.5rem">${escapeHtml(error)}</div></div></body></html>`
174
+
175
+ interface PendingOAuth {
176
+ pkce: PkceCodes
177
+ state: string
178
+ resolve: (tokens: TokenResponse) => void
179
+ reject: (error: Error) => void
180
+ }
181
+
182
+ let oauthServer: import("http").Server | undefined
183
+ let pendingOAuth: PendingOAuth | undefined
184
+
185
+ async function startOAuthServer(): Promise<{ redirectUri: string }> {
186
+ const redirectUri = `http://localhost:${OAUTH_PORT}/auth/callback`
187
+ if (oauthServer) return { redirectUri }
188
+ const { createServer } = await import("node:http")
189
+ oauthServer = createServer((req, res) => {
190
+ const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
191
+ if (url.pathname !== "/auth/callback") {
192
+ res.writeHead(404)
193
+ res.end("Not found")
194
+ return
195
+ }
196
+ const code = url.searchParams.get("code")
197
+ const state = url.searchParams.get("state")
198
+ const error = url.searchParams.get("error_description") || url.searchParams.get("error")
199
+ const fail = (status: number, msg: string) => {
200
+ pendingOAuth?.reject(new Error(msg))
201
+ pendingOAuth = undefined
202
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" })
203
+ res.end(renderOAuthError(msg))
204
+ }
205
+ if (error) return fail(200, error)
206
+ if (!code) return fail(400, "Missing authorization code")
207
+ if (!pendingOAuth || state !== pendingOAuth.state) return fail(400, "Invalid state - potential CSRF attack")
208
+ const current = pendingOAuth
209
+ pendingOAuth = undefined
210
+ exchangeCodeForTokens(code, redirectUri, current.pkce)
211
+ .then((tokens) => current.resolve(tokens))
212
+ .catch((err) => current.reject(err))
213
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
214
+ res.end(HTML_SUCCESS)
215
+ })
216
+ await new Promise<void>((resolve, reject) => {
217
+ oauthServer!.listen(OAUTH_PORT, resolve)
218
+ oauthServer!.on("error", reject)
219
+ })
220
+ return { redirectUri }
221
+ }
222
+
223
+ function stopOAuthServer(): void {
224
+ oauthServer?.close(() => {})
225
+ oauthServer = undefined
226
+ }
227
+
228
+ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
229
+ // A new login supersedes any in-flight one: reject the old promise so it
230
+ // can't hang or later clobber this flow's state.
231
+ pendingOAuth?.reject(new Error("OAuth flow superseded by a new login"))
232
+ return new Promise((resolve, reject) => {
233
+ let entry: PendingOAuth | undefined
234
+ // Each handler clears the shared slot only if it still owns it, so a stale
235
+ // timer or callback never nulls out a newer flow's pendingOAuth.
236
+ const clearIfOwner = () => {
237
+ clearTimeout(timeout)
238
+ if (pendingOAuth === entry) pendingOAuth = undefined
239
+ }
240
+ const timeout = setTimeout(() => {
241
+ if (pendingOAuth === entry) {
242
+ pendingOAuth = undefined
243
+ reject(new Error("OAuth callback timeout - authorization took too long"))
244
+ }
245
+ }, 5 * 60 * 1000)
246
+ entry = {
247
+ pkce,
248
+ state,
249
+ resolve: (tokens) => {
250
+ clearIfOwner()
251
+ resolve(tokens)
252
+ },
253
+ reject: (error) => {
254
+ clearIfOwner()
255
+ reject(error)
256
+ },
257
+ }
258
+ pendingOAuth = entry
259
+ })
260
+ }
261
+
262
+ // ---- Plugin --------------------------------------------------------------
263
+
264
+ export const WeaveCodex: Plugin = async (input: PluginInput): Promise<Hooks> => {
265
+ return {
266
+ auth: {
267
+ provider: PROVIDER_ID,
268
+ async loader(getAuth) {
269
+ const auth = await getAuth()
270
+ if (auth.type !== "oauth") return {}
271
+
272
+ // Coalesce concurrent refreshes (opencode fires parallel turns).
273
+ let refreshPromise: Promise<{ access: string; accountId: string | undefined }> | undefined
274
+
275
+ return {
276
+ apiKey: DUMMY_KEY,
277
+ async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
278
+ const currentAuth = (await getAuth()) as {
279
+ type: string
280
+ access: string
281
+ refresh: string
282
+ expires: number
283
+ accountId?: string
284
+ }
285
+ if (currentAuth.type !== "oauth") return fetch(requestInput, init)
286
+
287
+ // Refresh the access token on (or just before) expiry and persist
288
+ // the rotated refresh token back into opencode's auth store.
289
+ if (!currentAuth.access || currentAuth.expires < Date.now()) {
290
+ if (!refreshPromise) {
291
+ refreshPromise = refreshAccessToken(currentAuth.refresh)
292
+ .then(async (tokens) => {
293
+ const accountId = extractAccountId(tokens) || currentAuth.accountId
294
+ await input.client.auth.set({
295
+ path: { id: PROVIDER_ID },
296
+ body: {
297
+ type: "oauth",
298
+ // OAuth 2.0 lets the issuer omit a new refresh_token on
299
+ // refresh (the existing one stays valid). Keep the
300
+ // stored one in that case rather than clearing it.
301
+ refresh: tokens.refresh_token || currentAuth.refresh,
302
+ access: tokens.access_token,
303
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
304
+ ...(accountId && { accountId }),
305
+ },
306
+ })
307
+ return { access: tokens.access_token, accountId }
308
+ })
309
+ .finally(() => {
310
+ refreshPromise = undefined
311
+ })
312
+ }
313
+ const refreshed = await refreshPromise
314
+ currentAuth.access = refreshed.access
315
+ currentAuth.accountId = refreshed.accountId
316
+ }
317
+
318
+ // Preserve the configured headers (X-Weave-Router-Key, X-App, ...
319
+ // from opencode.json options.headers), then overwrite Authorization
320
+ // with the caller's subscription bearer + the paired account id.
321
+ const headers = new Headers(init?.headers as HeadersInit | undefined)
322
+ headers.set("authorization", `Bearer ${currentAuth.access}`)
323
+ if (currentAuth.accountId) headers.set("ChatGPT-Account-Id", currentAuth.accountId)
324
+
325
+ // NOTE: unlike opencode's bundled codex plugin we deliberately do
326
+ // NOT rewrite the URL — the request stays on the Weave Router, which
327
+ // forwards it to the Codex backend on the caller's plan.
328
+ return fetch(requestInput, { ...init, headers })
329
+ },
330
+ }
331
+ },
332
+ methods: [
333
+ {
334
+ label: "ChatGPT Pro/Plus (browser)",
335
+ type: "oauth",
336
+ authorize: async () => {
337
+ const { redirectUri } = await startOAuthServer()
338
+ const pkce = await generatePKCE()
339
+ const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
340
+ const callbackPromise = waitForOAuthCallback(pkce, state)
341
+ return {
342
+ url: buildAuthorizeUrl(redirectUri, pkce, state),
343
+ instructions: "Complete authorization in your browser. This window will close automatically.",
344
+ method: "auto" as const,
345
+ callback: async () => {
346
+ const tokens = await callbackPromise
347
+ stopOAuthServer()
348
+ return {
349
+ type: "success" as const,
350
+ refresh: tokens.refresh_token,
351
+ access: tokens.access_token,
352
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
353
+ accountId: extractAccountId(tokens),
354
+ }
355
+ },
356
+ }
357
+ },
358
+ },
359
+ {
360
+ label: "ChatGPT Pro/Plus (headless device code)",
361
+ type: "oauth",
362
+ authorize: async () => {
363
+ const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
364
+ method: "POST",
365
+ headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
366
+ body: JSON.stringify({ client_id: CLIENT_ID }),
367
+ })
368
+ if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
369
+ const deviceData = (await deviceResponse.json()) as {
370
+ device_auth_id: string
371
+ user_code: string
372
+ interval: string
373
+ }
374
+ const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
375
+ return {
376
+ url: `${ISSUER}/codex/device`,
377
+ instructions: `Enter code: ${deviceData.user_code}`,
378
+ method: "auto" as const,
379
+ async callback() {
380
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
381
+ while (true) {
382
+ const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
383
+ method: "POST",
384
+ headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
385
+ body: JSON.stringify({
386
+ device_auth_id: deviceData.device_auth_id,
387
+ user_code: deviceData.user_code,
388
+ }),
389
+ })
390
+ if (response.ok) {
391
+ const data = (await response.json()) as { authorization_code: string; code_verifier: string }
392
+ const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
393
+ method: "POST",
394
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
395
+ body: new URLSearchParams({
396
+ grant_type: "authorization_code",
397
+ code: data.authorization_code,
398
+ redirect_uri: `${ISSUER}/deviceauth/callback`,
399
+ client_id: CLIENT_ID,
400
+ code_verifier: data.code_verifier,
401
+ }).toString(),
402
+ })
403
+ if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${tokenResponse.status}`)
404
+ const tokens = (await tokenResponse.json()) as TokenResponse
405
+ return {
406
+ type: "success" as const,
407
+ refresh: tokens.refresh_token,
408
+ access: tokens.access_token,
409
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
410
+ accountId: extractAccountId(tokens),
411
+ }
412
+ }
413
+ if (response.status !== 403 && response.status !== 404) return { type: "failed" as const }
414
+ await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
415
+ }
416
+ },
417
+ }
418
+ },
419
+ },
420
+ ],
421
+ },
422
+ // The Codex backend (which the router forwards to) keys session continuity
423
+ // off these headers; mirror opencode's bundled codex plugin. Scoped to our
424
+ // provider so other providers are untouched.
425
+ "chat.headers": async (hookInput, output) => {
426
+ if (hookInput.model.providerID !== PROVIDER_ID) return
427
+ output.headers["originator"] = "codex_cli_ts"
428
+ output.headers["session-id"] = hookInput.sessionID
429
+ },
430
+ "chat.params": async (hookInput, output) => {
431
+ if (hookInput.model.providerID !== PROVIDER_ID) return
432
+ // Match codex cli: the Codex backend rejects an explicit max output cap.
433
+ output.maxOutputTokens = undefined
434
+ },
435
+ }
436
+ }
437
+
438
+ export default WeaveCodex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workweave/router",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "One-command installer that points Claude Code, Codex, opencode, or pi at the Weave Router. For pi it also ships the routing extension, loaded via pi.extensions.",
5
5
  "bin": {
6
6
  "weave-router": "bin.js"
@@ -17,6 +17,7 @@
17
17
  "cc-statusline.sh",
18
18
  "commands/",
19
19
  "pi-router/",
20
+ "opencode-weave/",
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],
@@ -301,7 +301,7 @@ export function registerDispatch(pi: ExtensionAPI, selfPath: string): void {
301
301
  if (!key) {
302
302
  return {
303
303
  content: [
304
- { type: "text", text: "Weave dispatch unavailable: no router key (set WEAVE_ROUTER_KEY or run the --pi installer)." },
304
+ { type: "text", text: "Weave Router dispatch unavailable: no router key (set WEAVE_ROUTER_KEY or run the --pi installer)." },
305
305
  ],
306
306
  details: { results: [] as ChildResult[] },
307
307
  isError: true,
@@ -337,7 +337,7 @@ export function registerDispatch(pi: ExtensionAPI, selfPath: string): void {
337
337
  const preview = t.prompt.length > 60 ? `${t.prompt.slice(0, 60)}...` : t.prompt;
338
338
  text += `\n ${theme.fg("dim", preview)}`;
339
339
  }
340
- if (tasks.length > 3) text += `\n ${theme.fg("muted", `... +${tasks.length - 3} more`)}`;
340
+ if (tasks.length > 3) text += `\n ${theme.fg("muted", `… +${tasks.length - 3} more`)}`;
341
341
  return new Text(text, 0, 0);
342
342
  },
343
343
  });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @workweave/router — route the pi coding agent through the WorkWeave Router.
2
+ * @workweave/router — route the pi coding agent through the Weave Router.
3
3
  *
4
4
  * Wiring (all on the existing router surface — no router source change beyond
5
5
  * the installer):
@@ -28,7 +28,7 @@ export function registerWeave(pi: ExtensionAPI): void {
28
28
  // key there is fatal rather than silently routing on quality knobs.
29
29
  if (isSubagent()) {
30
30
  throw new Error(
31
- "Weave: no router key found (set WEAVE_ROUTER_KEY or write ~/.pi/agent/.weave_router_key).",
31
+ "Weave Router: no router key found (set WEAVE_ROUTER_KEY or write ~/.pi/agent/.weave_router_key).",
32
32
  );
33
33
  }
34
34
  return;
@@ -23,7 +23,7 @@ export function registerRoutedModel(pi: ExtensionAPI): void {
23
23
 
24
24
  if (ctx.hasUI) {
25
25
  ctx.ui.setStatus(STATUS_KEY, `routed: ${model}`);
26
- ctx.ui.notify(`Weave routed to ${model}`, "info");
26
+ ctx.ui.notify(`Weave Router routed to ${model}`, "info");
27
27
  } else {
28
28
  process.stderr.write(`${ROUTED_MODEL_STDERR_PREFIX} ${model}\n`);
29
29
  }
package/uninstall.sh CHANGED
@@ -54,7 +54,7 @@ while [ $# -gt 0 ]; do
54
54
  ;;
55
55
  --dir)
56
56
  install_dir="${2:-}"; shift 2
57
- [ -n "$install_dir" ] || { err "--dir requires a path"; exit 2; }
57
+ [ -n "$install_dir" ] || { err "--dir requires a path."; exit 2; }
58
58
  ;;
59
59
  --codex)
60
60
  target="codex"; shift
@@ -78,7 +78,7 @@ while [ $# -gt 0 ]; do
78
78
  exit 0
79
79
  ;;
80
80
  *)
81
- err "unknown flag: $1"; exit 2
81
+ err "Unknown flag: $1. Run --help for usage."; exit 2
82
82
  ;;
83
83
  esac
84
84
  done
@@ -136,7 +136,7 @@ if [ "$target" = "opencode" ]; then
136
136
  "~/"*) project_dir="$HOME/${project_dir#~/}" ;;
137
137
  esac
138
138
  if [ ! -d "$project_dir" ]; then
139
- err "directory does not exist: $project_dir"
139
+ err "Directory does not exist: $project_dir."
140
140
  exit 1
141
141
  fi
142
142
  project_dir="$(cd "$project_dir" && pwd)"
@@ -155,14 +155,28 @@ if [ "$target" = "opencode" ]; then
155
155
  opencode_config_file="$opencode_dir/opencode.json"
156
156
  refuse_if_symlink "$opencode_config_file"
157
157
 
158
+ # Canonicalize the plugin path exactly as install.sh did (`cd … && pwd`) so
159
+ # the `plugin` array entry matches on removal — a raw "$opencode_dir/…" string
160
+ # can differ (symlinks, trailing slash) and leave the entry behind.
161
+ if [ -d "$opencode_dir" ]; then
162
+ opencode_plugin="$(cd "$opencode_dir" && pwd)/.weave/opencode-weave.ts"
163
+ else
164
+ opencode_plugin="$opencode_dir/.weave/opencode-weave.ts"
165
+ fi
158
166
  if [ -f "$opencode_config_file" ]; then
159
- # Strip `provider.weave` and any router-pointing top-level model. Other
160
- # providers, user-set models that don't reference the router, and any
161
- # unrelated keys are preserved.
162
- cleaned="$(jq '
167
+ # Strip both managed providers (`weave`, `weave-codex`), the managed plugin
168
+ # entry from the `plugin` array, and any router-pointing top-level model
169
+ # (both the `weave/` and `weave-codex/` provider prefixes — otherwise a
170
+ # `weave-codex/...` default survives and points at the deleted provider).
171
+ # Other providers, user-set models that don't reference the router, other
172
+ # plugins, and any unrelated keys are preserved.
173
+ cleaned="$(jq --arg plugin "$opencode_plugin" '
163
174
  (if .provider.weave then del(.provider.weave) else . end)
175
+ | (if .provider["weave-codex"] then del(.provider["weave-codex"]) else . end)
164
176
  | (if (.provider // {}) == {} then del(.provider) else . end)
165
- | (if (.model // "" | tostring | startswith("weave/")) then del(.model) else . end)
177
+ | (if (.plugin | type) == "array" then .plugin -= [$plugin] else . end)
178
+ | (if (.plugin | type) == "array" and (.plugin | length) == 0 then del(.plugin) else . end)
179
+ | (if (.model // "" | tostring | (startswith("weave/") or startswith("weave-codex/"))) then del(.model) else . end)
166
180
  ' "$opencode_config_file")"
167
181
  printf '%s\n' "$cleaned" >"$opencode_config_file"
168
182
 
@@ -179,6 +193,16 @@ if [ "$target" = "opencode" ]; then
179
193
  info "No opencode config at $opencode_config_file (already uninstalled?)"
180
194
  fi
181
195
 
196
+ # Drop the bundled Codex-subscription plugin (no secrets; the config holds the
197
+ # key, opencode's own auth store holds the ChatGPT tokens). Remove the .weave/
198
+ # dir only if it's left empty so we don't clobber an unrelated user dir.
199
+ if [ -f "$opencode_plugin" ]; then
200
+ refuse_if_symlink "$opencode_plugin"
201
+ rm -f "$opencode_plugin"
202
+ rmdir "$opencode_dir/.weave" 2>/dev/null || true
203
+ ok "Removed $opencode_plugin"
204
+ fi
205
+
182
206
  # Drop the toggle parked sidecar (holds the parked router model when off).
183
207
  opencode_parked="$opencode_dir/.weave-parked.json"
184
208
  if [ -f "$opencode_parked" ]; then
@@ -219,7 +243,7 @@ if [ "$target" = "pi" ]; then
219
243
  "~/"*) project_dir="$HOME/${project_dir#~/}" ;;
220
244
  esac
221
245
  if [ ! -d "$project_dir" ]; then
222
- err "directory does not exist: $project_dir"
246
+ err "Directory does not exist: $project_dir."
223
247
  exit 1
224
248
  fi
225
249
  project_dir="$(cd "$project_dir" && pwd)"
@@ -325,7 +349,7 @@ if [ "$target" = "codex" ]; then
325
349
  "~/"*) project_dir="$HOME/${project_dir#~/}" ;;
326
350
  esac
327
351
  if [ ! -d "$project_dir" ]; then
328
- err "directory does not exist: $project_dir"
352
+ err "Directory does not exist: $project_dir."
329
353
  exit 1
330
354
  fi
331
355
  project_dir="$(cd "$project_dir" && pwd)"
@@ -422,7 +446,7 @@ else
422
446
  "~/"*) project_dir="$HOME/${project_dir#~/}" ;;
423
447
  esac
424
448
  if [ ! -d "$project_dir" ]; then
425
- err "directory does not exist: $project_dir"
449
+ err "Directory does not exist: $project_dir."
426
450
  exit 1
427
451
  fi
428
452
  project_dir="$(cd "$project_dir" && pwd)"