@ex-machina/opencode-anthropic-auth 1.8.2 → 2.0.0-next.0
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 +33 -12
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +108 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +137 -167
- package/dist/transform.d.ts +2 -0
- package/dist/transform.js +271 -11
- package/package.json +10 -6
package/README.md
CHANGED
|
@@ -12,13 +12,22 @@
|
|
|
12
12
|
|
|
13
13
|
An [OpenCode](https://github.com/anomalyco/opencode) plugin that provides Anthropic OAuth authentication, enabling Claude Pro/Max users to use their subscription directly with OpenCode.
|
|
14
14
|
|
|
15
|
+
## Version compatibility
|
|
16
|
+
|
|
17
|
+
| Plugin version | OpenCode version | Package |
|
|
18
|
+
|-----------------|-------------------|---------------------------------------------|
|
|
19
|
+
| 2.x (this readme) | OpenCode v2 (beta plugin API) | `@ex-machina/opencode-anthropic-auth` |
|
|
20
|
+
| 1.x | OpenCode v1 | `@ex-machina/opencode-anthropic-auth@1` |
|
|
21
|
+
|
|
22
|
+
OpenCode v2's plugin API is still beta, and this plugin currently targets the `@opencode-ai/plugin@0.0.0-next-17444` prerelease of it — pin the plugin version and keep an eye on the changelog when bumping either side. If you're still on OpenCode v1, keep using a `1.x` release; v1 plugins are **not** loadable by OpenCode v2, and this v2 port is not loadable by OpenCode v1.
|
|
23
|
+
|
|
15
24
|
## Usage
|
|
16
25
|
|
|
17
26
|
Add the plugin to your OpenCode configuration:
|
|
18
27
|
|
|
19
28
|
```json
|
|
20
29
|
{
|
|
21
|
-
"
|
|
30
|
+
"plugins": ["@ex-machina/opencode-anthropic-auth"]
|
|
22
31
|
}
|
|
23
32
|
```
|
|
24
33
|
|
|
@@ -31,18 +40,22 @@ Add the plugin to your OpenCode configuration:
|
|
|
31
40
|
|
|
32
41
|
```json
|
|
33
42
|
{
|
|
34
|
-
"
|
|
43
|
+
"plugins": ["@ex-machina/opencode-anthropic-auth@<2.x.y-next.N>"]
|
|
35
44
|
}
|
|
36
45
|
```
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
The v2 line ships as prereleases on npm's `next` tag, so substitute a version that actually exists — `npm view @ex-machina/opencode-anthropic-auth dist-tags` shows the current one. Pin that exact version rather than tracking `@next`, which moves on every prerelease publish.
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
## Authentication Methods
|
|
41
50
|
|
|
42
51
|
- **Claude Pro/Max** - OAuth flow via `claude.ai` for Pro/Max subscribers. Uses your existing subscription at no additional API cost.
|
|
43
|
-
- run the `/connect` command, select `Anthropic
|
|
44
|
-
- **
|
|
45
|
-
|
|
52
|
+
- run the `/connect` command, select `Anthropic` -> `Claude Pro/Max` and do OAuth
|
|
53
|
+
- **Manually enter API Key / `ANTHROPIC_API_KEY`** - Handled by OpenCode's built-in Anthropic integration, not by this plugin.
|
|
54
|
+
|
|
55
|
+
> [!NOTE]
|
|
56
|
+
> The v1 release of this plugin also offered a "Create an API Key" OAuth flow (via `console.anthropic.com`) that minted and stored an API key for you. OpenCode v2's plugin API does not yet support an OAuth authorization flow that ends in a stored API key, so that flow isn't available in this v2 release. Use manual API key entry (or `ANTHROPIC_API_KEY`) in the meantime — see [issue #203](https://github.com/ex-machina-co/opencode-anthropic-auth/issues/203) for status.
|
|
57
|
+
>
|
|
58
|
+
> OpenCode v2 continues to display Anthropic's API prices for these models even though requests authenticated through Claude Pro/Max use the subscription. Dynamic cost display is deferred until the beta plugin API can safely cancel the required connection event subscription.
|
|
46
59
|
|
|
47
60
|
## Configuration
|
|
48
61
|
|
|
@@ -51,7 +64,7 @@ The plugin supports the following environment variables:
|
|
|
51
64
|
| Variable | Description |
|
|
52
65
|
|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
53
66
|
| `ANTHROPIC_BASE_URL` | Override the API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL. |
|
|
54
|
-
| `ANTHROPIC_INSECURE` |
|
|
67
|
+
| `ANTHROPIC_INSECURE` | **Not supported under OpenCode v2.** OpenCode v2 plugin request hooks can rewrite a request but cannot disable TLS verification for it. If this is set, the plugin logs a warning and leaves TLS verification enabled — requests to a self-signed/untrusted `ANTHROPIC_BASE_URL` will fail. |
|
|
55
68
|
|
|
56
69
|
## How It Works
|
|
57
70
|
|
|
@@ -62,7 +75,6 @@ For Claude Pro/Max authentication, the plugin:
|
|
|
62
75
|
3. Automatically refreshes expired tokens
|
|
63
76
|
4. Injects the required OAuth headers and beta flags into API requests
|
|
64
77
|
5. Sanitizes the system prompt for compatibility (see below)
|
|
65
|
-
6. Zeros out model costs (since usage is covered by the subscription)
|
|
66
78
|
|
|
67
79
|
### System Prompt Sanitization
|
|
68
80
|
|
|
@@ -90,7 +102,14 @@ This does three things:
|
|
|
90
102
|
2. Symlinks the build output into `.opencode/plugins/` so OpenCode loads it as a local plugin
|
|
91
103
|
3. Starts `tsc --watch` for automatic rebuilds on source changes
|
|
92
104
|
|
|
93
|
-
After starting the dev script, restart OpenCode in this project directory to pick up the local build. Any edits to `src/` will trigger a rebuild — restart OpenCode again to load the new version.
|
|
105
|
+
After starting the dev script, restart OpenCode v2 (`opencode2`) in this project directory to pick up the local build. Any edits to `src/` will trigger a rebuild — restart OpenCode again to load the new version.
|
|
106
|
+
|
|
107
|
+
You can confirm the plugin loaded correctly via the OpenCode v2 API:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
opencode2 api get /api/plugin # should list "ex-machina.anthropic-auth"
|
|
111
|
+
opencode2 api get /api/integration # anthropic should offer a "Claude Pro/Max" OAuth method
|
|
112
|
+
```
|
|
94
113
|
|
|
95
114
|
Ctrl+C stops the watcher and cleans up the symlink. If the process was killed without cleanup (e.g. `kill -9`), you can manually remove the symlink:
|
|
96
115
|
|
|
@@ -103,13 +122,15 @@ bun run dev:clean
|
|
|
103
122
|
|
|
104
123
|
### Publishing
|
|
105
124
|
|
|
106
|
-
This project uses [changesets](https://github.com/changesets/changesets) for versioning and publishing. See the [changeset README](.changeset/README.md) for
|
|
125
|
+
This project uses [changesets](https://github.com/changesets/changesets) for versioning and publishing. See the [changeset README](.changeset/README.md) for contributor details.
|
|
107
126
|
|
|
108
127
|
```bash
|
|
109
128
|
bun change # create a changeset describing your changes
|
|
110
129
|
```
|
|
111
130
|
|
|
112
|
-
|
|
131
|
+
Changesets merged to a release branch cause CI to open a release PR; merging that PR publishes to npm. This repository runs two release trains — `main` publishes the v1 line to npm's `latest`, and `v2/main` publishes the v2 line to `next` as `2.x.y-next.N` prereleases.
|
|
132
|
+
|
|
133
|
+
Maintainers: see [RELEASING.md](RELEASING.md) for the full runbook, including how `main` is synced into `v2/main`.
|
|
113
134
|
|
|
114
135
|
## License
|
|
115
136
|
|
package/dist/auth.d.ts
CHANGED
|
@@ -14,3 +14,19 @@ export type ExchangeResult = {
|
|
|
14
14
|
type: 'failed';
|
|
15
15
|
};
|
|
16
16
|
export declare function exchange(input: string, verifier: string, redirectUri: string, expectedState?: string): Promise<ExchangeResult>;
|
|
17
|
+
export type RefreshResult = {
|
|
18
|
+
type: 'success';
|
|
19
|
+
refresh: string;
|
|
20
|
+
access: string;
|
|
21
|
+
expires: number;
|
|
22
|
+
} | {
|
|
23
|
+
type: 'failed';
|
|
24
|
+
status: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Exchange a refresh token for a new access/refresh token pair.
|
|
28
|
+
* Retries transient (5xx, network) failures with exponential backoff;
|
|
29
|
+
* non-transient failures (e.g. 403 on a revoked/rotated-away token)
|
|
30
|
+
* are returned immediately as `{ type: 'failed' }`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function refreshToken(refreshTokenValue: string): Promise<RefreshResult>;
|
package/dist/auth.js
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import { AUTHORIZE_URLS, CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES, TOKEN_URL, } from "./constants.js";
|
|
2
2
|
import { generatePKCE } from "./pkce.js";
|
|
3
|
+
const REFRESH_TIMEOUT_MS = 30_000;
|
|
4
|
+
function isTokenResponse(value) {
|
|
5
|
+
if (typeof value !== 'object' || value === null)
|
|
6
|
+
return false;
|
|
7
|
+
if (!('refresh_token' in value) || !('access_token' in value))
|
|
8
|
+
return false;
|
|
9
|
+
if (!('expires_in' in value))
|
|
10
|
+
return false;
|
|
11
|
+
return (typeof value.refresh_token === 'string' &&
|
|
12
|
+
value.refresh_token.length > 0 &&
|
|
13
|
+
typeof value.access_token === 'string' &&
|
|
14
|
+
value.access_token.length > 0 &&
|
|
15
|
+
typeof value.expires_in === 'number' &&
|
|
16
|
+
Number.isSafeInteger(value.expires_in) &&
|
|
17
|
+
value.expires_in > 0);
|
|
18
|
+
}
|
|
19
|
+
async function parseTokenResponse(response) {
|
|
20
|
+
try {
|
|
21
|
+
const value = await response.json();
|
|
22
|
+
if (!isTokenResponse(value))
|
|
23
|
+
return undefined;
|
|
24
|
+
const expires = Date.now() + value.expires_in * 1000;
|
|
25
|
+
if (!Number.isSafeInteger(expires))
|
|
26
|
+
return undefined;
|
|
27
|
+
return {
|
|
28
|
+
refresh: value.refresh_token,
|
|
29
|
+
access: value.access_token,
|
|
30
|
+
expires,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof SyntaxError)
|
|
35
|
+
return undefined;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
3
39
|
function generateState() {
|
|
4
40
|
return crypto.randomUUID().replace(/-/g, '');
|
|
5
41
|
}
|
|
@@ -50,12 +86,12 @@ async function exchangeCode(callback, verifier, redirectUri) {
|
|
|
50
86
|
type: 'failed',
|
|
51
87
|
};
|
|
52
88
|
}
|
|
53
|
-
const
|
|
89
|
+
const tokens = await parseTokenResponse(result);
|
|
90
|
+
if (!tokens)
|
|
91
|
+
return { type: 'failed' };
|
|
54
92
|
return {
|
|
55
93
|
type: 'success',
|
|
56
|
-
|
|
57
|
-
access: json.access_token,
|
|
58
|
-
expires: Date.now() + json.expires_in * 1000,
|
|
94
|
+
...tokens,
|
|
59
95
|
};
|
|
60
96
|
}
|
|
61
97
|
export async function authorize(mode) {
|
|
@@ -91,3 +127,71 @@ export async function exchange(input, verifier, redirectUri, expectedState) {
|
|
|
91
127
|
}
|
|
92
128
|
return exchangeCode(callback, verifier, redirectUri);
|
|
93
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Exchange a refresh token for a new access/refresh token pair.
|
|
132
|
+
* Retries transient (5xx, network) failures with exponential backoff;
|
|
133
|
+
* non-transient failures (e.g. 403 on a revoked/rotated-away token)
|
|
134
|
+
* are returned immediately as `{ type: 'failed' }`.
|
|
135
|
+
*/
|
|
136
|
+
export async function refreshToken(refreshTokenValue) {
|
|
137
|
+
const maxRetries = 2;
|
|
138
|
+
const baseDelayMs = 500;
|
|
139
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
140
|
+
try {
|
|
141
|
+
if (attempt > 0) {
|
|
142
|
+
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
144
|
+
}
|
|
145
|
+
const response = await fetch(TOKEN_URL, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
Accept: 'application/json, text/plain, */*',
|
|
151
|
+
'User-Agent': 'axios/1.13.6',
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
grant_type: 'refresh_token',
|
|
155
|
+
refresh_token: refreshTokenValue,
|
|
156
|
+
client_id: CLIENT_ID,
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
161
|
+
await response.body?.cancel();
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
await response.body?.cancel();
|
|
165
|
+
return { type: 'failed', status: response.status };
|
|
166
|
+
}
|
|
167
|
+
const tokens = await parseTokenResponse(response);
|
|
168
|
+
if (!tokens) {
|
|
169
|
+
return { type: 'failed', status: response.status };
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
type: 'success',
|
|
173
|
+
...tokens,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const isNetworkError = (typeof error === 'object' &&
|
|
178
|
+
error !== null &&
|
|
179
|
+
'name' in error &&
|
|
180
|
+
(error.name === 'TimeoutError' || error.name === 'AbortError')) ||
|
|
181
|
+
(error instanceof Error &&
|
|
182
|
+
(error.message.includes('fetch failed') ||
|
|
183
|
+
('code' in error &&
|
|
184
|
+
(error.code === 'ECONNRESET' ||
|
|
185
|
+
error.code === 'ECONNREFUSED' ||
|
|
186
|
+
error.code === 'ETIMEDOUT' ||
|
|
187
|
+
error.code === 'UND_ERR_CONNECT_TIMEOUT'))));
|
|
188
|
+
if (attempt < maxRetries && isNetworkError) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Unreachable — each iteration either returns or throws.
|
|
195
|
+
// Kept as a TypeScript exhaustiveness guard.
|
|
196
|
+
throw new Error('Token refresh exhausted all retries');
|
|
197
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
declare const _default: Plugin.Plugin;
|
|
3
|
+
export default _default;
|
package/dist/index.js
CHANGED
|
@@ -1,175 +1,145 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
import { authorize, exchange, refreshToken } from "./auth.js";
|
|
3
|
+
import { REQUIRED_BETAS } from "./constants.js";
|
|
3
4
|
import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
|
|
4
|
-
|
|
5
|
+
const PLUGIN_ID = 'ex-machina.anthropic-auth';
|
|
6
|
+
const INTEGRATION_ID = 'anthropic';
|
|
7
|
+
const REFRESH_CACHE_GRACE_MS = 30_000;
|
|
8
|
+
// `methodID` is a branded `Integration.MethodID` at the type level (a
|
|
9
|
+
// compile-time-only tag — there's no runtime representation), so a plain
|
|
10
|
+
// string literal needs a cast to satisfy the branded field.
|
|
11
|
+
const METHOD_ID = 'claude-max';
|
|
12
|
+
function toCredential(exchanged) {
|
|
5
13
|
return {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
type: 'oauth',
|
|
15
|
+
methodID: METHOD_ID,
|
|
16
|
+
refresh: exchanged.refresh,
|
|
17
|
+
access: exchanged.access,
|
|
18
|
+
expires: exchanged.expires,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async function resolveActiveOAuth(ctx) {
|
|
22
|
+
const connection = await ctx.integration.connection.active(INTEGRATION_ID);
|
|
23
|
+
if (!connection)
|
|
24
|
+
return undefined;
|
|
25
|
+
const credential = await ctx.integration.connection.resolve(connection);
|
|
26
|
+
if (credential?.type === 'oauth' && credential.methodID === METHOD_ID) {
|
|
27
|
+
return credential;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function warnIfInsecureUnsupported() {
|
|
32
|
+
if (!isInsecure())
|
|
33
|
+
return;
|
|
34
|
+
console.warn('[ex-machina.anthropic-auth] ANTHROPIC_INSECURE is set, but OpenCode v2 ' +
|
|
35
|
+
'plugin request hooks cannot disable TLS verification for a custom ' +
|
|
36
|
+
'ANTHROPIC_BASE_URL endpoint. TLS verification remains enabled — ' +
|
|
37
|
+
'requests to an untrusted/self-signed endpoint will fail.');
|
|
38
|
+
}
|
|
39
|
+
function isTransformedOAuthRequest(request) {
|
|
40
|
+
const betas = new Set((request.headers.get('anthropic-beta') ?? '')
|
|
41
|
+
.split(',')
|
|
42
|
+
.map((beta) => beta.trim()));
|
|
43
|
+
return (request.headers.get('authorization')?.startsWith('Bearer ') === true &&
|
|
44
|
+
REQUIRED_BETAS.every((beta) => betas.has(beta)) &&
|
|
45
|
+
new URL(request.url).searchParams.get('beta') === 'true');
|
|
46
|
+
}
|
|
47
|
+
export default Plugin.define({
|
|
48
|
+
id: PLUGIN_ID,
|
|
49
|
+
setup: async (ctx) => {
|
|
50
|
+
warnIfInsecureUnsupported();
|
|
51
|
+
// Retain successful refreshes for this plugin generation so a host call
|
|
52
|
+
// holding the rotated token cannot submit it again before persistence.
|
|
53
|
+
const refreshInFlight = new Map();
|
|
54
|
+
const refreshCredential = async (credential) => {
|
|
55
|
+
const existing = refreshInFlight.get(credential.refresh);
|
|
56
|
+
if (existing)
|
|
57
|
+
return existing;
|
|
58
|
+
const pending = (async () => {
|
|
59
|
+
const result = await refreshToken(credential.refresh);
|
|
60
|
+
if (result.type === 'failed') {
|
|
61
|
+
throw new Error(`Anthropic token refresh failed: ${result.status}`);
|
|
62
|
+
}
|
|
63
|
+
return toCredential(result);
|
|
64
|
+
})();
|
|
65
|
+
refreshInFlight.set(credential.refresh, pending);
|
|
66
|
+
try {
|
|
67
|
+
const rotated = await pending;
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
if (refreshInFlight.get(credential.refresh) === pending) {
|
|
70
|
+
refreshInFlight.delete(credential.refresh);
|
|
21
71
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
72
|
+
}, REFRESH_CACHE_GRACE_MS);
|
|
73
|
+
timer.unref?.();
|
|
74
|
+
return rotated;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (refreshInFlight.get(credential.refresh) === pending) {
|
|
78
|
+
refreshInFlight.delete(credential.refresh);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await ctx.integration.transform((draft) => {
|
|
84
|
+
draft.method.update({
|
|
85
|
+
integrationID: INTEGRATION_ID,
|
|
86
|
+
method: {
|
|
87
|
+
id: METHOD_ID,
|
|
88
|
+
type: 'oauth',
|
|
89
|
+
label: 'Claude Pro/Max',
|
|
90
|
+
},
|
|
91
|
+
authorize: async () => {
|
|
92
|
+
const result = await authorize('max');
|
|
25
93
|
return {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const maxRetries = 2;
|
|
35
|
-
const baseDelayMs = 500;
|
|
36
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
37
|
-
try {
|
|
38
|
-
if (attempt > 0) {
|
|
39
|
-
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
40
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
41
|
-
}
|
|
42
|
-
// Re-read auth to get the latest refresh token.
|
|
43
|
-
// The outer `auth` snapshot may be stale if tokens
|
|
44
|
-
// were rotated since the fetch() call was made.
|
|
45
|
-
const freshAuth = await getAuth();
|
|
46
|
-
const response = await fetch(TOKEN_URL, {
|
|
47
|
-
method: 'POST',
|
|
48
|
-
headers: {
|
|
49
|
-
'Content-Type': 'application/json',
|
|
50
|
-
Accept: 'application/json, text/plain, */*',
|
|
51
|
-
'User-Agent': 'axios/1.13.6',
|
|
52
|
-
},
|
|
53
|
-
body: JSON.stringify({
|
|
54
|
-
grant_type: 'refresh_token',
|
|
55
|
-
refresh_token: freshAuth.refresh,
|
|
56
|
-
client_id: CLIENT_ID,
|
|
57
|
-
}),
|
|
58
|
-
});
|
|
59
|
-
if (!response.ok) {
|
|
60
|
-
if (response.status >= 500 && attempt < maxRetries) {
|
|
61
|
-
await response.body?.cancel();
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const body = await response.text().catch(() => '');
|
|
65
|
-
throw new Error(`Token refresh failed: ${response.status} — ${body}`);
|
|
66
|
-
}
|
|
67
|
-
const json = (await response.json());
|
|
68
|
-
// biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
|
|
69
|
-
await client.auth.set({
|
|
70
|
-
path: {
|
|
71
|
-
id: 'anthropic',
|
|
72
|
-
},
|
|
73
|
-
body: {
|
|
74
|
-
type: 'oauth',
|
|
75
|
-
refresh: json.refresh_token,
|
|
76
|
-
access: json.access_token,
|
|
77
|
-
expires: Date.now() + json.expires_in * 1000,
|
|
78
|
-
},
|
|
79
|
-
});
|
|
80
|
-
return json.access_token;
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
const isNetworkError = error instanceof Error &&
|
|
84
|
-
(error.message.includes('fetch failed') ||
|
|
85
|
-
('code' in error &&
|
|
86
|
-
(error.code === 'ECONNRESET' ||
|
|
87
|
-
error.code === 'ECONNREFUSED' ||
|
|
88
|
-
error.code === 'ETIMEDOUT' ||
|
|
89
|
-
error.code === 'UND_ERR_CONNECT_TIMEOUT')));
|
|
90
|
-
if (attempt < maxRetries && isNetworkError) {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
throw error;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
// Unreachable — each iteration either returns or throws.
|
|
97
|
-
// Kept as a TypeScript exhaustiveness guard.
|
|
98
|
-
throw new Error('Token refresh exhausted all retries');
|
|
99
|
-
})().finally(() => {
|
|
100
|
-
refreshPromise = null;
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
auth.access = await refreshPromise;
|
|
104
|
-
}
|
|
105
|
-
const requestHeaders = mergeHeaders(input, init);
|
|
106
|
-
// biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
|
|
107
|
-
setOAuthHeaders(requestHeaders, auth.access);
|
|
108
|
-
let body = init?.body;
|
|
109
|
-
if (body && typeof body === 'string') {
|
|
110
|
-
body = rewriteRequestBody(body);
|
|
94
|
+
url: result.url,
|
|
95
|
+
instructions: 'Paste the authorization code here:',
|
|
96
|
+
mode: 'code',
|
|
97
|
+
callback: async (code) => {
|
|
98
|
+
const exchanged = await exchange(code, result.verifier, result.redirectUri, result.state);
|
|
99
|
+
if (exchanged.type === 'failed') {
|
|
100
|
+
throw new Error('Failed to exchange the Claude Pro/Max authorization code. ' +
|
|
101
|
+
'Double-check that you pasted the full code and try again.');
|
|
111
102
|
}
|
|
112
|
-
|
|
113
|
-
const response = await fetch(rewritten.input, {
|
|
114
|
-
...init,
|
|
115
|
-
body,
|
|
116
|
-
headers: requestHeaders,
|
|
117
|
-
...(isInsecure() && { tls: { rejectUnauthorized: false } }),
|
|
118
|
-
});
|
|
119
|
-
return createStrippedStream(response);
|
|
103
|
+
return toCredential(exchanged);
|
|
120
104
|
},
|
|
121
105
|
};
|
|
122
|
-
}
|
|
123
|
-
return {};
|
|
124
|
-
},
|
|
125
|
-
methods: [
|
|
126
|
-
{
|
|
127
|
-
label: 'Claude Pro/Max',
|
|
128
|
-
type: 'oauth',
|
|
129
|
-
authorize: async () => {
|
|
130
|
-
const result = await authorize('max');
|
|
131
|
-
return {
|
|
132
|
-
url: result.url,
|
|
133
|
-
instructions: 'Paste the authorization code here:',
|
|
134
|
-
method: 'code',
|
|
135
|
-
callback: async (code) => {
|
|
136
|
-
return exchange(code, result.verifier, result.redirectUri, result.state);
|
|
137
|
-
},
|
|
138
|
-
};
|
|
139
|
-
},
|
|
140
106
|
},
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
107
|
+
refresh: refreshCredential,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
await ctx.session.hook('http.request', async (event) => {
|
|
111
|
+
if (event.model.providerID !== INTEGRATION_ID)
|
|
112
|
+
return;
|
|
113
|
+
const credential = await resolveActiveOAuth(ctx);
|
|
114
|
+
if (!credential)
|
|
115
|
+
return;
|
|
116
|
+
const request = event.request;
|
|
117
|
+
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
|
118
|
+
const bodyText = hasBody ? await request.clone().text() : undefined;
|
|
119
|
+
const rewrittenBody = bodyText !== undefined ? rewriteRequestBody(bodyText) : undefined;
|
|
120
|
+
const headers = mergeHeaders(request);
|
|
121
|
+
setOAuthHeaders(headers, credential.access);
|
|
122
|
+
if (rewrittenBody !== undefined)
|
|
123
|
+
headers.delete('content-length');
|
|
124
|
+
const { input: rewrittenInput } = rewriteUrl(request.url);
|
|
125
|
+
const url = typeof rewrittenInput === 'string'
|
|
126
|
+
? rewrittenInput
|
|
127
|
+
: rewrittenInput instanceof Request
|
|
128
|
+
? rewrittenInput.url
|
|
129
|
+
: rewrittenInput.toString();
|
|
130
|
+
event.request = new Request(url, {
|
|
131
|
+
method: request.method,
|
|
132
|
+
headers,
|
|
133
|
+
body: rewrittenBody,
|
|
134
|
+
signal: request.signal,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
await ctx.session.hook('http.response', (event) => {
|
|
138
|
+
if (event.model.providerID !== INTEGRATION_ID)
|
|
139
|
+
return;
|
|
140
|
+
if (!isTransformedOAuthRequest(event.request))
|
|
141
|
+
return;
|
|
142
|
+
event.response = createStrippedStream(event.response);
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
});
|
package/dist/transform.d.ts
CHANGED
package/dist/transform.js
CHANGED
|
@@ -1,5 +1,208 @@
|
|
|
1
1
|
import { buildBillingHeaderValue } from "./cch.js";
|
|
2
2
|
import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, USER_AGENT, } from "./constants.js";
|
|
3
|
+
// Bound an incomplete SSE line so malformed streams cannot grow memory forever.
|
|
4
|
+
export const MAX_SSE_LINE_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
function headersAfterBodyTransform(source) {
|
|
6
|
+
const headers = new Headers(source);
|
|
7
|
+
for (const name of [
|
|
8
|
+
'content-digest',
|
|
9
|
+
'content-encoding',
|
|
10
|
+
'content-length',
|
|
11
|
+
'content-md5',
|
|
12
|
+
'content-range',
|
|
13
|
+
'digest',
|
|
14
|
+
'etag',
|
|
15
|
+
]) {
|
|
16
|
+
headers.delete(name);
|
|
17
|
+
}
|
|
18
|
+
return headers;
|
|
19
|
+
}
|
|
20
|
+
const JSON_NAME_KEY_SUFFIX = new TextEncoder().encode('name"');
|
|
21
|
+
const JSON_TOOL_PREFIX = new TextEncoder().encode(TOOL_PREFIX);
|
|
22
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
23
|
+
const UTF8_FATAL_DECODER = new TextDecoder('utf-8', { fatal: true });
|
|
24
|
+
export const MAX_JSON_TOOL_NAME_BYTES = 1024;
|
|
25
|
+
function isJsonWhitespace(byte) {
|
|
26
|
+
return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Rewrite JSON `name` string values without buffering the whole document.
|
|
30
|
+
* Only a bounded tool-name candidate is retained across chunks; all document
|
|
31
|
+
* content outside that string value is emitted immediately.
|
|
32
|
+
*/
|
|
33
|
+
function createJsonToolNameStream(body) {
|
|
34
|
+
let state = 'outside';
|
|
35
|
+
let held = [];
|
|
36
|
+
let candidateIndex = 0;
|
|
37
|
+
let escaped = false;
|
|
38
|
+
const enterStringAfter = (byte) => {
|
|
39
|
+
if (byte === 0x22) {
|
|
40
|
+
state = 'outside';
|
|
41
|
+
escaped = false;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
state = 'string';
|
|
45
|
+
escaped = byte === 0x5c;
|
|
46
|
+
};
|
|
47
|
+
return body.pipeThrough(new TransformStream({
|
|
48
|
+
transform(chunk, controller) {
|
|
49
|
+
const output = new Uint8Array(chunk.byteLength + 32);
|
|
50
|
+
let outputLength = 0;
|
|
51
|
+
const write = (byte) => {
|
|
52
|
+
output[outputLength++] = byte;
|
|
53
|
+
};
|
|
54
|
+
const enqueueOutput = () => {
|
|
55
|
+
if (outputLength === 0)
|
|
56
|
+
return;
|
|
57
|
+
controller.enqueue(output.slice(0, outputLength));
|
|
58
|
+
outputLength = 0;
|
|
59
|
+
};
|
|
60
|
+
const writeHeld = () => {
|
|
61
|
+
for (const byte of held)
|
|
62
|
+
write(byte);
|
|
63
|
+
held = [];
|
|
64
|
+
};
|
|
65
|
+
const processOutside = (byte) => {
|
|
66
|
+
if (byte === 0x22) {
|
|
67
|
+
held = [byte];
|
|
68
|
+
candidateIndex = 0;
|
|
69
|
+
state = 'key-candidate';
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
write(byte);
|
|
73
|
+
};
|
|
74
|
+
for (const byte of chunk) {
|
|
75
|
+
if (state === 'outside') {
|
|
76
|
+
processOutside(byte);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (state === 'key-candidate') {
|
|
80
|
+
if (byte === JSON_NAME_KEY_SUFFIX[candidateIndex]) {
|
|
81
|
+
held.push(byte);
|
|
82
|
+
candidateIndex++;
|
|
83
|
+
if (candidateIndex === JSON_NAME_KEY_SUFFIX.byteLength) {
|
|
84
|
+
writeHeld();
|
|
85
|
+
state = 'after-name-key';
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
writeHeld();
|
|
90
|
+
write(byte);
|
|
91
|
+
enterStringAfter(byte);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (state === 'string') {
|
|
95
|
+
write(byte);
|
|
96
|
+
if (escaped) {
|
|
97
|
+
escaped = false;
|
|
98
|
+
}
|
|
99
|
+
else if (byte === 0x5c) {
|
|
100
|
+
escaped = true;
|
|
101
|
+
}
|
|
102
|
+
else if (byte === 0x22) {
|
|
103
|
+
state = 'outside';
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (state === 'after-name-key') {
|
|
108
|
+
if (isJsonWhitespace(byte)) {
|
|
109
|
+
write(byte);
|
|
110
|
+
}
|
|
111
|
+
else if (byte === 0x3a) {
|
|
112
|
+
write(byte);
|
|
113
|
+
state = 'after-colon';
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
processOutside(byte);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (state === 'after-colon') {
|
|
121
|
+
if (isJsonWhitespace(byte)) {
|
|
122
|
+
write(byte);
|
|
123
|
+
}
|
|
124
|
+
else if (byte === 0x22) {
|
|
125
|
+
write(byte);
|
|
126
|
+
held = [];
|
|
127
|
+
candidateIndex = 0;
|
|
128
|
+
state = 'prefix-candidate';
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
processOutside(byte);
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (state === 'prefix-candidate') {
|
|
136
|
+
if (byte === JSON_TOOL_PREFIX[candidateIndex]) {
|
|
137
|
+
held.push(byte);
|
|
138
|
+
candidateIndex++;
|
|
139
|
+
if (candidateIndex === JSON_TOOL_PREFIX.byteLength) {
|
|
140
|
+
held = [];
|
|
141
|
+
candidateIndex = 0;
|
|
142
|
+
escaped = false;
|
|
143
|
+
state = 'tool-name-candidate';
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
writeHeld();
|
|
148
|
+
write(byte);
|
|
149
|
+
enterStringAfter(byte);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (escaped) {
|
|
153
|
+
held.push(byte);
|
|
154
|
+
escaped = false;
|
|
155
|
+
if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
|
|
156
|
+
throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (byte === 0x5c) {
|
|
161
|
+
held.push(byte);
|
|
162
|
+
escaped = true;
|
|
163
|
+
if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
|
|
164
|
+
throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (byte === 0x22) {
|
|
169
|
+
let replacement;
|
|
170
|
+
if (held.length === 0) {
|
|
171
|
+
replacement = JSON_TOOL_PREFIX;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
try {
|
|
175
|
+
replacement = UTF8_ENCODER.encode(unprefixName(UTF8_FATAL_DECODER.decode(Uint8Array.from(held))));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
replacement = Uint8Array.from([...JSON_TOOL_PREFIX, ...held]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
enqueueOutput();
|
|
182
|
+
controller.enqueue(replacement);
|
|
183
|
+
write(byte);
|
|
184
|
+
held = [];
|
|
185
|
+
candidateIndex = 0;
|
|
186
|
+
state = 'outside';
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
held.push(byte);
|
|
190
|
+
if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
|
|
191
|
+
throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
enqueueOutput();
|
|
195
|
+
},
|
|
196
|
+
flush(controller) {
|
|
197
|
+
const trailing = Uint8Array.from(state === 'tool-name-candidate'
|
|
198
|
+
? [...JSON_TOOL_PREFIX, ...held]
|
|
199
|
+
: held);
|
|
200
|
+
if (trailing.byteLength === 0)
|
|
201
|
+
return;
|
|
202
|
+
controller.enqueue(trailing);
|
|
203
|
+
},
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
3
206
|
/**
|
|
4
207
|
* Prefix a tool name with TOOL_PREFIX and uppercase the first character.
|
|
5
208
|
* Claude Code uses PascalCase tool names (e.g. mcp_Bash, mcp_Read);
|
|
@@ -289,26 +492,83 @@ export function rewriteRequestBody(body) {
|
|
|
289
492
|
* Create a streaming response that strips the tool prefix from tool names.
|
|
290
493
|
*/
|
|
291
494
|
export function createStrippedStream(response) {
|
|
495
|
+
const mediaType = response.headers
|
|
496
|
+
.get('content-type')
|
|
497
|
+
?.split(';', 1)[0]
|
|
498
|
+
?.trim()
|
|
499
|
+
.toLowerCase();
|
|
292
500
|
if (!response.body)
|
|
293
501
|
return response;
|
|
294
|
-
|
|
502
|
+
if (mediaType === 'application/json' || mediaType?.endsWith('+json')) {
|
|
503
|
+
const stream = createJsonToolNameStream(response.body);
|
|
504
|
+
const headers = headersAfterBodyTransform(response.headers);
|
|
505
|
+
return new Response(stream, {
|
|
506
|
+
status: response.status,
|
|
507
|
+
statusText: response.statusText,
|
|
508
|
+
headers,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
if (mediaType !== 'text/event-stream')
|
|
512
|
+
return response;
|
|
295
513
|
const decoder = new TextDecoder();
|
|
296
514
|
const encoder = new TextEncoder();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
515
|
+
let pending = new Uint8Array(0);
|
|
516
|
+
let pendingLength = 0;
|
|
517
|
+
const appendPending = (bytes) => {
|
|
518
|
+
const requiredLength = pendingLength + bytes.byteLength;
|
|
519
|
+
if (requiredLength > MAX_SSE_LINE_BYTES) {
|
|
520
|
+
throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
|
|
521
|
+
}
|
|
522
|
+
if (requiredLength > pending.byteLength) {
|
|
523
|
+
let capacity = Math.max(1024, pending.byteLength);
|
|
524
|
+
while (capacity < requiredLength) {
|
|
525
|
+
capacity = Math.min(MAX_SSE_LINE_BYTES, capacity * 2);
|
|
526
|
+
}
|
|
527
|
+
const expanded = new Uint8Array(capacity);
|
|
528
|
+
expanded.set(pending.subarray(0, pendingLength));
|
|
529
|
+
pending = expanded;
|
|
530
|
+
}
|
|
531
|
+
pending.set(bytes, pendingLength);
|
|
532
|
+
pendingLength = requiredLength;
|
|
533
|
+
};
|
|
534
|
+
const stream = response.body.pipeThrough(new TransformStream({
|
|
535
|
+
transform(chunk, controller) {
|
|
536
|
+
let lastLineBreak = -1;
|
|
537
|
+
let lineLength = pendingLength;
|
|
538
|
+
for (let index = 0; index < chunk.byteLength; index++) {
|
|
539
|
+
if (chunk[index] === 0x0a || chunk[index] === 0x0d) {
|
|
540
|
+
lastLineBreak = index;
|
|
541
|
+
lineLength = 0;
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
lineLength++;
|
|
545
|
+
if (lineLength > MAX_SSE_LINE_BYTES) {
|
|
546
|
+
throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (lastLineBreak < 0) {
|
|
551
|
+
appendPending(chunk);
|
|
302
552
|
return;
|
|
303
553
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
554
|
+
const completeLines = decoder.decode(pending.subarray(0, pendingLength), {
|
|
555
|
+
stream: true,
|
|
556
|
+
}) + decoder.decode(chunk.subarray(0, lastLineBreak + 1));
|
|
557
|
+
pendingLength = 0;
|
|
558
|
+
appendPending(chunk.subarray(lastLineBreak + 1));
|
|
559
|
+
controller.enqueue(encoder.encode(stripToolPrefix(completeLines)));
|
|
307
560
|
},
|
|
308
|
-
|
|
561
|
+
flush(controller) {
|
|
562
|
+
const trailing = decoder.decode(pending.subarray(0, pendingLength));
|
|
563
|
+
if (trailing) {
|
|
564
|
+
controller.enqueue(encoder.encode(stripToolPrefix(trailing)));
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
}));
|
|
568
|
+
const headers = headersAfterBodyTransform(response.headers);
|
|
309
569
|
return new Response(stream, {
|
|
310
570
|
status: response.status,
|
|
311
571
|
statusText: response.statusText,
|
|
312
|
-
headers
|
|
572
|
+
headers,
|
|
313
573
|
});
|
|
314
574
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ex-machina/opencode-anthropic-auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-next.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"repository": {
|
|
5
6
|
"type": "git",
|
|
6
7
|
"url": "https://github.com/ex-machina-co/opencode-anthropic-auth"
|
|
7
8
|
},
|
|
8
9
|
"main": "./dist/index.js",
|
|
9
10
|
"types": "./dist/index.d.ts",
|
|
11
|
+
"packageManager": "bun@1.3.14",
|
|
10
12
|
"engines": {
|
|
11
13
|
"bun": "1.3.14"
|
|
12
14
|
},
|
|
@@ -19,26 +21,28 @@
|
|
|
19
21
|
"dev": "bun scripts/dev.ts",
|
|
20
22
|
"dev:clean": "bun scripts/dev-clean.ts",
|
|
21
23
|
"extract": "bun scripts/extract-system-prompt.ts",
|
|
24
|
+
"check": "bun turbo check:all",
|
|
22
25
|
"test": "bun test",
|
|
23
26
|
"types": "tsc",
|
|
24
27
|
"format": "biome check --write --unsafe",
|
|
25
28
|
"format:check": "biome format .",
|
|
26
|
-
"lint": "biome lint .",
|
|
29
|
+
"lint": "biome lint --error-on-warnings .",
|
|
27
30
|
"change": "changeset",
|
|
28
|
-
"release": "bun run build && bun change publish"
|
|
31
|
+
"release": "bun run build && bun change publish",
|
|
32
|
+
"release:next": "bun scripts/validate-next-release.ts && bun run release"
|
|
29
33
|
},
|
|
30
|
-
"
|
|
31
|
-
"@opencode-ai/plugin": "
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@opencode-ai/plugin": "0.0.0-next-17444"
|
|
32
36
|
},
|
|
33
37
|
"devDependencies": {
|
|
34
38
|
"@biomejs/biome": "2.5.2",
|
|
35
39
|
"@changesets/changelog-github": "^0.7.0",
|
|
36
40
|
"@changesets/cli": "^2.31.0",
|
|
37
|
-
"@opencode-ai/plugin": "1.17.13",
|
|
38
41
|
"@tsconfig/bun": "1.0.10",
|
|
39
42
|
"@types/bun": "1.3.14",
|
|
40
43
|
"dedent": "^1.7.2",
|
|
41
44
|
"lefthook": "2.1.9",
|
|
45
|
+
"turbo": "2.9.18",
|
|
42
46
|
"typescript": "6.0.3"
|
|
43
47
|
}
|
|
44
48
|
}
|