@askalf/dario 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AskAlf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,317 @@
1
+ <p align="center">
2
+ <h1 align="center">dario</h1>
3
+ <p align="center"><strong>Use your Claude subscription as an API.</strong></p>
4
+ <p align="center">
5
+ Two commands. No API key. Your Claude Max/Pro subscription becomes a local API endpoint<br/>that any tool, SDK, or framework can use.
6
+ </p>
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="#quick-start">Quick Start</a> &bull;
11
+ <a href="#how-it-works">How It Works</a> &bull;
12
+ <a href="#usage-examples">Examples</a> &bull;
13
+ <a href="#faq">FAQ</a>
14
+ </p>
15
+
16
+ ---
17
+
18
+ ```bash
19
+ npx dario login # authenticate with Claude
20
+ npx dario proxy # start local API on :3456
21
+
22
+ # now use it from anywhere
23
+ export ANTHROPIC_BASE_URL=http://localhost:3456
24
+ export ANTHROPIC_API_KEY=dario
25
+ ```
26
+
27
+ That's it. Any tool that speaks the Anthropic API now uses your subscription.
28
+
29
+ ---
30
+
31
+ ## The Problem
32
+
33
+ You pay $100-200/mo for Claude Max or Pro. But that subscription only works on claude.ai and Claude Code. If you want to use Claude with **any other tool** — OpenClaw, Cursor, Continue, Aider, your own scripts — you need a separate API key with separate billing.
34
+
35
+ **dario fixes this.** It creates a local proxy that translates API key auth into your subscription's OAuth tokens. Your tools don't know the difference.
36
+
37
+ ## Quick Start
38
+
39
+ ### Install
40
+
41
+ ```bash
42
+ npm install -g dario
43
+ ```
44
+
45
+ Or use npx (no install needed):
46
+
47
+ ```bash
48
+ npx dario login
49
+ npx dario proxy
50
+ ```
51
+
52
+ ### Login
53
+
54
+ ```bash
55
+ dario login
56
+ ```
57
+
58
+ Opens your browser to Claude's OAuth page. Log in, authorize, paste the redirect URL back. Takes 10 seconds.
59
+
60
+ ### Start the proxy
61
+
62
+ ```bash
63
+ dario proxy
64
+ ```
65
+
66
+ ```
67
+ dario v1.0.0 — http://localhost:3456
68
+
69
+ Your Claude subscription is now an API.
70
+
71
+ Usage:
72
+ ANTHROPIC_BASE_URL=http://localhost:3456
73
+ ANTHROPIC_API_KEY=dario
74
+
75
+ OAuth: healthy (expires in 11h 42m)
76
+ ```
77
+
78
+ ### Use it
79
+
80
+ ```bash
81
+ # Set these two env vars — every Anthropic SDK respects them
82
+ export ANTHROPIC_BASE_URL=http://localhost:3456
83
+ export ANTHROPIC_API_KEY=dario
84
+
85
+ # Now any tool just works
86
+ openclaw start
87
+ aider --model claude-opus-4-6
88
+ continue # in VS Code, set base URL in config
89
+ python my_script.py
90
+ ```
91
+
92
+ ## Usage Examples
93
+
94
+ ### curl
95
+
96
+ ```bash
97
+ curl http://localhost:3456/v1/messages \
98
+ -H "Content-Type: application/json" \
99
+ -H "anthropic-version: 2023-06-01" \
100
+ -d '{
101
+ "model": "claude-opus-4-6",
102
+ "max_tokens": 1024,
103
+ "messages": [{"role": "user", "content": "Hello!"}]
104
+ }'
105
+ ```
106
+
107
+ ### Python
108
+
109
+ ```python
110
+ import anthropic
111
+
112
+ client = anthropic.Anthropic(
113
+ base_url="http://localhost:3456",
114
+ api_key="dario"
115
+ )
116
+
117
+ message = client.messages.create(
118
+ model="claude-opus-4-6",
119
+ max_tokens=1024,
120
+ messages=[{"role": "user", "content": "Hello!"}]
121
+ )
122
+ print(message.content[0].text)
123
+ ```
124
+
125
+ ### TypeScript / Node.js
126
+
127
+ ```typescript
128
+ import Anthropic from "@anthropic-ai/sdk";
129
+
130
+ const client = new Anthropic({
131
+ baseURL: "http://localhost:3456",
132
+ apiKey: "dario",
133
+ });
134
+
135
+ const message = await client.messages.create({
136
+ model: "claude-opus-4-6",
137
+ max_tokens: 1024,
138
+ messages: [{ role: "user", content: "Hello!" }],
139
+ });
140
+ ```
141
+
142
+ ### Streaming
143
+
144
+ ```bash
145
+ curl http://localhost:3456/v1/messages \
146
+ -H "Content-Type: application/json" \
147
+ -H "anthropic-version: 2023-06-01" \
148
+ -d '{
149
+ "model": "claude-sonnet-4-6",
150
+ "max_tokens": 1024,
151
+ "stream": true,
152
+ "messages": [{"role": "user", "content": "Write a haiku about APIs"}]
153
+ }'
154
+ ```
155
+
156
+ ### With Other Tools
157
+
158
+ ```bash
159
+ # OpenClaw
160
+ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario openclaw start
161
+
162
+ # Aider
163
+ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario aider --model claude-opus-4-6
164
+
165
+ # Any tool that uses ANTHROPIC_BASE_URL
166
+ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario your-tool-here
167
+ ```
168
+
169
+ ## How It Works
170
+
171
+ ```
172
+ ┌──────────┐ ┌─────────────────┐ ┌──────────────────┐
173
+ │ Your App │ ──> │ dario (proxy) │ ──> │ api.anthropic.com│
174
+ │ │ │ localhost:3456 │ │ │
175
+ │ sends │ │ swaps API key │ │ sees valid │
176
+ │ API key │ │ for OAuth │ │ OAuth bearer │
177
+ │ "dario" │ │ bearer token │ │ token │
178
+ └──────────┘ └─────────────────┘ └──────────────────┘
179
+ ```
180
+
181
+ 1. **`dario login`** — Standard PKCE OAuth flow. Opens Claude's auth page in your browser. You authorize, dario stores the tokens locally in `~/.dario/credentials.json`. No server involved, no secrets leave your machine.
182
+
183
+ 2. **`dario proxy`** — Starts an HTTP server on localhost that implements the full [Anthropic Messages API](https://docs.anthropic.com/en/api/messages). When a request arrives, dario:
184
+ - Strips the incoming API key (you can use any string)
185
+ - Injects your OAuth access token as a Bearer header
186
+ - Forwards the request to `api.anthropic.com`
187
+ - Streams the response back to your app
188
+
189
+ 3. **Auto-refresh** — OAuth tokens expire. Dario refreshes them automatically in the background every 15 minutes. Refresh tokens rotate on each use. You never have to re-login unless you explicitly log out.
190
+
191
+ ## Commands
192
+
193
+ | Command | Description |
194
+ |---------|-------------|
195
+ | `dario login` | Authenticate with your Claude account |
196
+ | `dario proxy` | Start the local API proxy |
197
+ | `dario status` | Check if your token is healthy |
198
+ | `dario refresh` | Force an immediate token refresh |
199
+ | `dario logout` | Delete stored credentials |
200
+ | `dario help` | Show usage information |
201
+
202
+ ### Proxy Options
203
+
204
+ | Flag | Description | Default |
205
+ |------|-------------|---------|
206
+ | `--port=PORT` | Port to listen on | `3456` |
207
+ | `--verbose` / `-v` | Log every request with token counts | off |
208
+
209
+ ## Supported Features
210
+
211
+ Everything the Anthropic Messages API supports:
212
+
213
+ - All Claude models (`opus-4-6`, `sonnet-4-6`, `haiku-4-5`)
214
+ - Streaming and non-streaming
215
+ - Tool use / function calling
216
+ - System prompts and multi-turn conversations
217
+ - Prompt caching
218
+ - Extended thinking
219
+ - All `anthropic-beta` features (headers pass through)
220
+ - CORS enabled (works from browser apps on localhost)
221
+
222
+ ## Endpoints
223
+
224
+ | Path | Description |
225
+ |------|-------------|
226
+ | `POST /v1/messages` | Anthropic Messages API (main endpoint) |
227
+ | `GET /health` | Proxy health + OAuth status + request count |
228
+ | `GET /status` | Detailed OAuth token status |
229
+
230
+ ## Health Check
231
+
232
+ ```bash
233
+ curl http://localhost:3456/health
234
+ ```
235
+
236
+ ```json
237
+ {
238
+ "status": "ok",
239
+ "oauth": "healthy",
240
+ "expiresIn": "11h 42m",
241
+ "requests": 47
242
+ }
243
+ ```
244
+
245
+ ## Security
246
+
247
+ | Concern | How dario handles it |
248
+ |---------|---------------------|
249
+ | Credential storage | `~/.dario/credentials.json` with `0600` permissions (owner-only) |
250
+ | OAuth flow | PKCE (Proof Key for Code Exchange) — no client secret needed |
251
+ | Token transmission | OAuth tokens never leave localhost. Only forwarded to `api.anthropic.com` over HTTPS |
252
+ | Network exposure | Proxy binds to `localhost` only — not accessible from other machines |
253
+ | Token rotation | Refresh tokens rotate on every use (single-use) |
254
+ | Data collection | Zero. No telemetry, no analytics, no phoning home |
255
+
256
+ ## FAQ
257
+
258
+ **Does this violate Anthropic's terms of service?**
259
+ Dario uses the same public OAuth client ID and PKCE flow that Claude Code uses. It authenticates you as you, with your subscription, through Anthropic's official OAuth endpoints. It doesn't bypass any access controls — it just bridges the auth method.
260
+
261
+ **What subscription plans work?**
262
+ Claude Max and Claude Pro. Any plan that lets you use Claude Code.
263
+
264
+ **Does it work with Claude Team / Enterprise?**
265
+ Should work if your plan includes Claude Code access. Not tested yet — please open an issue with results.
266
+
267
+ **What happens when my token expires?**
268
+ Dario auto-refreshes tokens 30 minutes before expiry. You should never see an auth error in normal use. If something goes wrong, `dario refresh` forces an immediate refresh, or `dario login` to re-authenticate.
269
+
270
+ **Can I run this on a server?**
271
+ Dario binds to localhost by default. For server use, you'd need to handle the initial browser-based login on a machine with a browser, then copy `~/.dario/credentials.json` to your server. Auto-refresh will keep it alive from there.
272
+
273
+ **What's the rate limit?**
274
+ Whatever your subscription plan provides. Dario doesn't add any limits — it's a transparent proxy.
275
+
276
+ **Why "dario"?**
277
+ Named after [Dario Amodei](https://en.wikipedia.org/wiki/Dario_Amodei), CEO of Anthropic.
278
+
279
+ ## Programmatic API
280
+
281
+ Use dario as a library in your own Node.js app:
282
+
283
+ ```typescript
284
+ import { startProxy, getAccessToken, getStatus } from "dario";
285
+
286
+ // Start the proxy programmatically
287
+ await startProxy({ port: 3456, verbose: true });
288
+
289
+ // Or just get a raw access token
290
+ const token = await getAccessToken();
291
+
292
+ // Check token health
293
+ const status = await getStatus();
294
+ console.log(status.expiresIn); // "11h 42m"
295
+ ```
296
+
297
+ ## Contributing
298
+
299
+ PRs welcome. The codebase is ~500 lines of TypeScript across 4 files:
300
+
301
+ | File | Purpose |
302
+ |------|---------|
303
+ | `src/oauth.ts` | PKCE flow, token storage, refresh logic |
304
+ | `src/proxy.ts` | HTTP proxy server |
305
+ | `src/cli.ts` | CLI entry point |
306
+ | `src/index.ts` | Library exports |
307
+
308
+ ```bash
309
+ git clone https://github.com/askalf/dario
310
+ cd dario
311
+ npm install
312
+ npm run dev # runs with tsx (no build needed)
313
+ ```
314
+
315
+ ## License
316
+
317
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dario — Use your Claude subscription as an API.
4
+ *
5
+ * Usage:
6
+ * dario login — Authenticate with your Claude account
7
+ * dario status — Check token health
8
+ * dario proxy — Start the API proxy (default: port 3456)
9
+ * dario refresh — Force token refresh
10
+ * dario logout — Remove saved credentials
11
+ */
12
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dario — Use your Claude subscription as an API.
4
+ *
5
+ * Usage:
6
+ * dario login — Authenticate with your Claude account
7
+ * dario status — Check token health
8
+ * dario proxy — Start the API proxy (default: port 3456)
9
+ * dario refresh — Force token refresh
10
+ * dario logout — Remove saved credentials
11
+ */
12
+ import { createInterface } from 'node:readline';
13
+ import { unlink } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { startOAuthFlow, exchangeCode, getStatus, refreshTokens } from './oauth.js';
17
+ import { startProxy } from './proxy.js';
18
+ const args = process.argv.slice(2);
19
+ const command = args[0] ?? 'proxy';
20
+ function ask(question) {
21
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
22
+ return new Promise(resolve => {
23
+ rl.question(question, answer => {
24
+ rl.close();
25
+ resolve(answer.trim());
26
+ });
27
+ });
28
+ }
29
+ async function login() {
30
+ console.log('');
31
+ console.log(' dario — Claude OAuth Login');
32
+ console.log(' ─────────────────────────');
33
+ console.log('');
34
+ const { authUrl, codeVerifier } = startOAuthFlow();
35
+ console.log(' Step 1: Open this URL in your browser:');
36
+ console.log('');
37
+ console.log(` ${authUrl}`);
38
+ console.log('');
39
+ console.log(' Step 2: Log in to your Claude account and authorize.');
40
+ console.log('');
41
+ console.log(' Step 3: After authorization, you\'ll be redirected to a page');
42
+ console.log(' that shows a code. Copy the FULL URL from your browser\'s');
43
+ console.log(' address bar (it contains the authorization code).');
44
+ console.log('');
45
+ const input = await ask(' Paste the redirect URL or authorization code: ');
46
+ // Extract code from URL or use raw input
47
+ let code = input;
48
+ try {
49
+ const url = new URL(input);
50
+ // Only extract from trusted Anthropic redirect URLs
51
+ if (url.hostname === 'platform.claude.com' || url.hostname === 'claude.ai') {
52
+ code = url.searchParams.get('code') ?? input;
53
+ }
54
+ }
55
+ catch {
56
+ // Not a URL, use as-is (raw code)
57
+ }
58
+ if (!code || code.length < 10 || code.length > 2048) {
59
+ console.error(' Invalid authorization code.');
60
+ process.exit(1);
61
+ }
62
+ try {
63
+ const tokens = await exchangeCode(code, codeVerifier);
64
+ const expiresIn = Math.round((tokens.expiresAt - Date.now()) / 60000);
65
+ console.log('');
66
+ console.log(' Login successful!');
67
+ console.log(` Token expires in ${expiresIn} minutes (auto-refreshes).`);
68
+ console.log('');
69
+ console.log(' Run `dario proxy` to start the API proxy.');
70
+ console.log('');
71
+ }
72
+ catch (err) {
73
+ console.error('');
74
+ console.error(` Login failed: ${err instanceof Error ? err.message : err}`);
75
+ console.error(' Try again with `dario login`.');
76
+ process.exit(1);
77
+ }
78
+ }
79
+ async function status() {
80
+ const s = await getStatus();
81
+ console.log('');
82
+ console.log(' dario — Status');
83
+ console.log(' ─────────────');
84
+ console.log('');
85
+ if (!s.authenticated) {
86
+ console.log(` Status: ${s.status === 'expired' ? 'Expired (will auto-refresh)' : 'Not authenticated'}`);
87
+ if (s.status === 'none') {
88
+ console.log(' Run `dario login` to authenticate.');
89
+ }
90
+ }
91
+ else {
92
+ console.log(` Status: ${s.status}`);
93
+ console.log(` Expires in: ${s.expiresIn}`);
94
+ }
95
+ console.log('');
96
+ }
97
+ async function refresh() {
98
+ console.log('[dario] Refreshing token...');
99
+ try {
100
+ const tokens = await refreshTokens();
101
+ const expiresIn = Math.round((tokens.expiresAt - Date.now()) / 60000);
102
+ console.log(`[dario] Token refreshed. Expires in ${expiresIn} minutes.`);
103
+ }
104
+ catch (err) {
105
+ console.error(`[dario] Refresh failed: ${err instanceof Error ? err.message : err}`);
106
+ process.exit(1);
107
+ }
108
+ }
109
+ async function logout() {
110
+ const path = join(homedir(), '.dario', 'credentials.json');
111
+ try {
112
+ await unlink(path);
113
+ console.log('[dario] Credentials removed.');
114
+ }
115
+ catch {
116
+ console.log('[dario] No credentials found.');
117
+ }
118
+ }
119
+ async function proxy() {
120
+ const portArg = args.find(a => a.startsWith('--port='));
121
+ const port = portArg ? parseInt(portArg.split('=')[1]) : 3456;
122
+ const verbose = args.includes('--verbose') || args.includes('-v');
123
+ await startProxy({ port, verbose });
124
+ }
125
+ async function help() {
126
+ console.log(`
127
+ dario — Use your Claude subscription as an API.
128
+
129
+ Usage:
130
+ dario login Authenticate with your Claude account
131
+ dario proxy [options] Start the API proxy server
132
+ dario status Check authentication status
133
+ dario refresh Force token refresh
134
+ dario logout Remove saved credentials
135
+
136
+ Proxy options:
137
+ --port=PORT Port to listen on (default: 3456)
138
+ --verbose, -v Log all requests
139
+
140
+ How it works:
141
+ 1. Run \`dario login\` to authenticate with your Claude Max/Pro subscription
142
+ 2. Run \`dario proxy\` to start a local API server
143
+ 3. Point any Anthropic SDK at http://localhost:3456
144
+
145
+ Example with OpenClaw, or any tool that uses the Anthropic API:
146
+ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario openclaw start
147
+
148
+ Example with the Anthropic Python SDK:
149
+ import anthropic
150
+ client = anthropic.Anthropic(base_url="http://localhost:3456", api_key="dario")
151
+
152
+ Example with curl:
153
+ curl http://localhost:3456/v1/messages \\
154
+ -H "Content-Type: application/json" \\
155
+ -d '{"model":"claude-sonnet-4-6","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}'
156
+
157
+ Your subscription handles the billing. No API key needed.
158
+ Tokens auto-refresh in the background — set it and forget it.
159
+ `);
160
+ }
161
+ // Main
162
+ const commands = {
163
+ login,
164
+ status,
165
+ proxy,
166
+ refresh,
167
+ logout,
168
+ help,
169
+ '--help': help,
170
+ '-h': help,
171
+ };
172
+ const handler = commands[command];
173
+ if (!handler) {
174
+ console.error(`Unknown command: ${command}`);
175
+ console.error('Run `dario help` for usage.');
176
+ process.exit(1);
177
+ }
178
+ handler().catch(err => {
179
+ console.error('Fatal error:', err);
180
+ process.exit(1);
181
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * dario — programmatic API
3
+ *
4
+ * Use this if you want to embed dario in your own app
5
+ * instead of running the CLI.
6
+ */
7
+ export { startOAuthFlow, exchangeCode, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
8
+ export type { OAuthTokens, CredentialsFile } from './oauth.js';
9
+ export { startProxy } from './proxy.js';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * dario — programmatic API
3
+ *
4
+ * Use this if you want to embed dario in your own app
5
+ * instead of running the CLI.
6
+ */
7
+ export { startOAuthFlow, exchangeCode, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
8
+ export { startProxy } from './proxy.js';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Dario — Claude OAuth Engine
3
+ *
4
+ * Full PKCE OAuth flow for Claude subscriptions.
5
+ * Handles authorization, token exchange, storage, and auto-refresh.
6
+ */
7
+ export interface OAuthTokens {
8
+ accessToken: string;
9
+ refreshToken: string;
10
+ expiresAt: number;
11
+ scopes: string[];
12
+ }
13
+ export interface CredentialsFile {
14
+ claudeAiOauth: OAuthTokens;
15
+ }
16
+ export declare function loadCredentials(): Promise<CredentialsFile | null>;
17
+ /**
18
+ * Start the OAuth flow. Returns the authorization URL and PKCE state
19
+ * needed for the exchange step.
20
+ */
21
+ export declare function startOAuthFlow(): {
22
+ authUrl: string;
23
+ state: string;
24
+ codeVerifier: string;
25
+ };
26
+ /**
27
+ * Exchange authorization code for tokens and save them.
28
+ */
29
+ export declare function exchangeCode(code: string, codeVerifier: string): Promise<OAuthTokens>;
30
+ /**
31
+ * Refresh the access token using the refresh token.
32
+ * Retries with exponential backoff on transient failures.
33
+ */
34
+ export declare function refreshTokens(): Promise<OAuthTokens>;
35
+ /**
36
+ * Get a valid access token, refreshing if needed.
37
+ */
38
+ export declare function getAccessToken(): Promise<string>;
39
+ /**
40
+ * Get token status info.
41
+ */
42
+ export declare function getStatus(): Promise<{
43
+ authenticated: boolean;
44
+ status: 'healthy' | 'expiring' | 'expired' | 'none';
45
+ expiresAt?: number;
46
+ expiresIn?: string;
47
+ }>;
package/dist/oauth.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Dario — Claude OAuth Engine
3
+ *
4
+ * Full PKCE OAuth flow for Claude subscriptions.
5
+ * Handles authorization, token exchange, storage, and auto-refresh.
6
+ */
7
+ import { randomBytes, createHash } from 'node:crypto';
8
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
9
+ import { dirname, join } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ // Claude CLI's public OAuth client (PKCE, no secret needed)
12
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
13
+ const OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
14
+ const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
15
+ const OAUTH_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
16
+ // Refresh 30 min before expiry
17
+ const REFRESH_BUFFER_MS = 30 * 60 * 1000;
18
+ function base64url(buf) {
19
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
20
+ }
21
+ function generatePKCE() {
22
+ const codeVerifier = base64url(randomBytes(32));
23
+ const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest());
24
+ return { codeVerifier, codeChallenge };
25
+ }
26
+ function getCredentialsPath() {
27
+ return join(homedir(), '.dario', 'credentials.json');
28
+ }
29
+ export async function loadCredentials() {
30
+ try {
31
+ const raw = await readFile(getCredentialsPath(), 'utf-8');
32
+ return JSON.parse(raw);
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ async function saveCredentials(creds) {
39
+ const path = getCredentialsPath();
40
+ await mkdir(dirname(path), { recursive: true });
41
+ // Write atomically: write to temp file, then rename
42
+ const tmpPath = `${path}.tmp.${Date.now()}`;
43
+ await writeFile(tmpPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
44
+ const { rename } = await import('node:fs/promises');
45
+ await rename(tmpPath, path);
46
+ // Set permissions (best-effort — no-op on Windows where mode is ignored)
47
+ try {
48
+ await chmod(path, 0o600);
49
+ }
50
+ catch { /* Windows ignores file modes */ }
51
+ }
52
+ /**
53
+ * Start the OAuth flow. Returns the authorization URL and PKCE state
54
+ * needed for the exchange step.
55
+ */
56
+ export function startOAuthFlow() {
57
+ const { codeVerifier, codeChallenge } = generatePKCE();
58
+ const state = base64url(randomBytes(16));
59
+ const params = new URLSearchParams({
60
+ response_type: 'code',
61
+ client_id: OAUTH_CLIENT_ID,
62
+ redirect_uri: OAUTH_REDIRECT_URI,
63
+ scope: 'user:inference user:profile',
64
+ state,
65
+ code_challenge: codeChallenge,
66
+ code_challenge_method: 'S256',
67
+ });
68
+ return {
69
+ authUrl: `${OAUTH_AUTHORIZE_URL}?${params.toString()}`,
70
+ state,
71
+ codeVerifier,
72
+ };
73
+ }
74
+ /**
75
+ * Exchange authorization code for tokens and save them.
76
+ */
77
+ export async function exchangeCode(code, codeVerifier) {
78
+ const res = await fetch(OAUTH_TOKEN_URL, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
81
+ body: new URLSearchParams({
82
+ grant_type: 'authorization_code',
83
+ client_id: OAUTH_CLIENT_ID,
84
+ code,
85
+ redirect_uri: OAUTH_REDIRECT_URI,
86
+ code_verifier: codeVerifier,
87
+ }),
88
+ });
89
+ if (!res.ok) {
90
+ const err = await res.text().catch(() => 'Unknown error');
91
+ throw new Error(`Token exchange failed (${res.status}): ${err}`);
92
+ }
93
+ const data = await res.json();
94
+ const tokens = {
95
+ accessToken: data.access_token,
96
+ refreshToken: data.refresh_token,
97
+ expiresAt: Date.now() + data.expires_in * 1000,
98
+ scopes: data.scope?.split(' ') || ['user:inference'],
99
+ };
100
+ await saveCredentials({ claudeAiOauth: tokens });
101
+ return tokens;
102
+ }
103
+ /**
104
+ * Refresh the access token using the refresh token.
105
+ * Retries with exponential backoff on transient failures.
106
+ */
107
+ export async function refreshTokens() {
108
+ const creds = await loadCredentials();
109
+ if (!creds?.claudeAiOauth?.refreshToken) {
110
+ throw new Error('No refresh token available. Run `dario login` first.');
111
+ }
112
+ const oauth = creds.claudeAiOauth;
113
+ for (let attempt = 0; attempt < 3; attempt++) {
114
+ if (attempt > 0)
115
+ await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
116
+ const res = await fetch(OAUTH_TOKEN_URL, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
119
+ body: new URLSearchParams({
120
+ grant_type: 'refresh_token',
121
+ refresh_token: oauth.refreshToken,
122
+ client_id: OAUTH_CLIENT_ID,
123
+ }),
124
+ signal: AbortSignal.timeout(15000),
125
+ });
126
+ if (!res.ok) {
127
+ if (res.status === 401 || res.status === 403) {
128
+ throw new Error(`Refresh token rejected (${res.status}). Run \`dario login\` to re-authenticate.`);
129
+ }
130
+ continue;
131
+ }
132
+ const data = await res.json();
133
+ const tokens = {
134
+ ...oauth,
135
+ accessToken: data.access_token,
136
+ refreshToken: data.refresh_token,
137
+ expiresAt: Date.now() + data.expires_in * 1000,
138
+ };
139
+ await saveCredentials({ claudeAiOauth: tokens });
140
+ return tokens;
141
+ }
142
+ throw new Error('Token refresh failed after 3 attempts');
143
+ }
144
+ /**
145
+ * Get a valid access token, refreshing if needed.
146
+ */
147
+ export async function getAccessToken() {
148
+ const creds = await loadCredentials();
149
+ if (!creds?.claudeAiOauth) {
150
+ throw new Error('Not authenticated. Run `dario login` first.');
151
+ }
152
+ const oauth = creds.claudeAiOauth;
153
+ // Still valid
154
+ if (oauth.expiresAt > Date.now() + REFRESH_BUFFER_MS) {
155
+ return oauth.accessToken;
156
+ }
157
+ // Need refresh
158
+ console.log('[dario] Token expiring soon, refreshing...');
159
+ const refreshed = await refreshTokens();
160
+ return refreshed.accessToken;
161
+ }
162
+ /**
163
+ * Get token status info.
164
+ */
165
+ export async function getStatus() {
166
+ const creds = await loadCredentials();
167
+ if (!creds?.claudeAiOauth?.accessToken) {
168
+ return { authenticated: false, status: 'none' };
169
+ }
170
+ const { expiresAt } = creds.claudeAiOauth;
171
+ const now = Date.now();
172
+ if (expiresAt < now) {
173
+ return { authenticated: false, status: 'expired', expiresAt };
174
+ }
175
+ const ms = expiresAt - now;
176
+ const hours = Math.floor(ms / 3600000);
177
+ const mins = Math.floor((ms % 3600000) / 60000);
178
+ const expiresIn = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
179
+ return {
180
+ authenticated: true,
181
+ status: ms < REFRESH_BUFFER_MS ? 'expiring' : 'healthy',
182
+ expiresAt,
183
+ expiresIn,
184
+ };
185
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Dario — API Proxy Server
3
+ *
4
+ * Sits between your app and the Anthropic API.
5
+ * Transparently swaps API key auth for OAuth bearer tokens.
6
+ *
7
+ * Point any Anthropic SDK client at http://localhost:3456 and it just works.
8
+ * No API key needed — your Claude subscription pays for it.
9
+ */
10
+ interface ProxyOptions {
11
+ port?: number;
12
+ verbose?: boolean;
13
+ }
14
+ export declare function startProxy(opts?: ProxyOptions): Promise<void>;
15
+ export {};
package/dist/proxy.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Dario — API Proxy Server
3
+ *
4
+ * Sits between your app and the Anthropic API.
5
+ * Transparently swaps API key auth for OAuth bearer tokens.
6
+ *
7
+ * Point any Anthropic SDK client at http://localhost:3456 and it just works.
8
+ * No API key needed — your Claude subscription pays for it.
9
+ */
10
+ import { createServer } from 'node:http';
11
+ import { getAccessToken, getStatus } from './oauth.js';
12
+ const ANTHROPIC_API = 'https://api.anthropic.com';
13
+ const DEFAULT_PORT = 3456;
14
+ const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
15
+ const ALLOWED_PATH_PREFIX = '/v1/'; // Only proxy Anthropic API paths
16
+ const LOCALHOST = '127.0.0.1';
17
+ const CORS_ORIGIN = 'http://localhost';
18
+ function sanitizeError(err) {
19
+ const msg = err instanceof Error ? err.message : String(err);
20
+ // Never leak tokens in error messages
21
+ return msg.replace(/sk-ant-[a-zA-Z0-9_-]+/g, '[REDACTED]');
22
+ }
23
+ export async function startProxy(opts = {}) {
24
+ const port = opts.port ?? DEFAULT_PORT;
25
+ const verbose = opts.verbose ?? false;
26
+ // Verify auth before starting
27
+ const status = await getStatus();
28
+ if (!status.authenticated) {
29
+ console.error('[dario] Not authenticated. Run `dario login` first.');
30
+ process.exit(1);
31
+ }
32
+ let requestCount = 0;
33
+ let tokenCostEstimate = 0;
34
+ const server = createServer(async (req, res) => {
35
+ // CORS preflight
36
+ if (req.method === 'OPTIONS') {
37
+ res.writeHead(204, {
38
+ 'Access-Control-Allow-Origin': CORS_ORIGIN,
39
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
40
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta',
41
+ 'Access-Control-Max-Age': '86400',
42
+ });
43
+ res.end();
44
+ return;
45
+ }
46
+ // Health check
47
+ if (req.url === '/health' || req.url === '/') {
48
+ const s = await getStatus();
49
+ res.writeHead(200, { 'Content-Type': 'application/json' });
50
+ res.end(JSON.stringify({
51
+ status: 'ok',
52
+ oauth: s.status,
53
+ expiresIn: s.expiresIn,
54
+ requests: requestCount,
55
+ }));
56
+ return;
57
+ }
58
+ // Status endpoint
59
+ if (req.url === '/status') {
60
+ const s = await getStatus();
61
+ res.writeHead(200, { 'Content-Type': 'application/json' });
62
+ res.end(JSON.stringify(s));
63
+ return;
64
+ }
65
+ // Only allow proxying to /v1/* paths — block path traversal
66
+ const urlPath = req.url?.split('?')[0] ?? '';
67
+ if (!urlPath.startsWith(ALLOWED_PATH_PREFIX)) {
68
+ res.writeHead(403, { 'Content-Type': 'application/json' });
69
+ res.end(JSON.stringify({ error: 'Forbidden', message: `Only ${ALLOWED_PATH_PREFIX}* paths are proxied` }));
70
+ return;
71
+ }
72
+ // Only allow POST (Messages API) and GET (models, etc)
73
+ if (req.method !== 'POST' && req.method !== 'GET') {
74
+ res.writeHead(405, { 'Content-Type': 'application/json' });
75
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
76
+ return;
77
+ }
78
+ // Proxy to Anthropic
79
+ try {
80
+ const accessToken = await getAccessToken();
81
+ requestCount++;
82
+ // Read request body with size limit
83
+ const chunks = [];
84
+ let totalBytes = 0;
85
+ for await (const chunk of req) {
86
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
87
+ totalBytes += buf.length;
88
+ if (totalBytes > MAX_BODY_BYTES) {
89
+ res.writeHead(413, { 'Content-Type': 'application/json' });
90
+ res.end(JSON.stringify({ error: 'Request body too large', max: `${MAX_BODY_BYTES / 1024 / 1024}MB` }));
91
+ return;
92
+ }
93
+ chunks.push(buf);
94
+ }
95
+ const body = Buffer.concat(chunks);
96
+ if (verbose) {
97
+ console.log(`[dario] #${requestCount} ${req.method} ${req.url}`);
98
+ }
99
+ // Forward to Anthropic with OAuth token + required beta flag
100
+ const targetUrl = `${ANTHROPIC_API}${req.url}`;
101
+ // Merge any client-provided beta flags with the required oauth flag
102
+ const clientBeta = req.headers['anthropic-beta'];
103
+ const betaFlags = new Set(['oauth-2025-04-20']);
104
+ if (clientBeta) {
105
+ for (const flag of clientBeta.split(',')) {
106
+ const trimmed = flag.trim();
107
+ if (trimmed.length > 0 && trimmed.length < 100)
108
+ betaFlags.add(trimmed);
109
+ }
110
+ }
111
+ const headers = {
112
+ 'Authorization': `Bearer ${accessToken}`,
113
+ 'Content-Type': 'application/json',
114
+ 'anthropic-version': req.headers['anthropic-version'] || '2023-06-01',
115
+ 'anthropic-beta': [...betaFlags].join(','),
116
+ 'x-app': 'cli',
117
+ };
118
+ const upstream = await fetch(targetUrl, {
119
+ method: req.method ?? 'POST',
120
+ headers,
121
+ body: body.length > 0 ? body : undefined,
122
+ // @ts-expect-error — duplex needed for streaming
123
+ duplex: 'half',
124
+ });
125
+ // Detect streaming from content-type (reliable) or body (fallback)
126
+ const contentType = upstream.headers.get('content-type') ?? '';
127
+ const isStream = contentType.includes('text/event-stream');
128
+ // Forward response headers
129
+ const responseHeaders = {
130
+ 'Content-Type': contentType || 'application/json',
131
+ 'Access-Control-Allow-Origin': CORS_ORIGIN,
132
+ };
133
+ // Forward rate limit headers
134
+ for (const h of ['x-ratelimit-limit-requests', 'x-ratelimit-limit-tokens', 'x-ratelimit-remaining-requests', 'x-ratelimit-remaining-tokens', 'request-id']) {
135
+ const v = upstream.headers.get(h);
136
+ if (v)
137
+ responseHeaders[h] = v;
138
+ }
139
+ res.writeHead(upstream.status, responseHeaders);
140
+ if (isStream && upstream.body) {
141
+ // Stream SSE chunks through
142
+ const reader = upstream.body.getReader();
143
+ try {
144
+ while (true) {
145
+ const { done, value } = await reader.read();
146
+ if (done)
147
+ break;
148
+ res.write(value);
149
+ }
150
+ }
151
+ catch (err) {
152
+ if (verbose)
153
+ console.error('[dario] Stream error:', sanitizeError(err));
154
+ }
155
+ res.end();
156
+ }
157
+ else {
158
+ // Buffer and forward
159
+ const responseBody = await upstream.text();
160
+ res.end(responseBody);
161
+ // Quick token estimate for logging
162
+ if (verbose && responseBody) {
163
+ try {
164
+ const parsed = JSON.parse(responseBody);
165
+ if (parsed.usage) {
166
+ const tokens = (parsed.usage.input_tokens ?? 0) + (parsed.usage.output_tokens ?? 0);
167
+ tokenCostEstimate += tokens;
168
+ console.log(`[dario] #${requestCount} ${upstream.status} — ${tokens} tokens (session total: ${tokenCostEstimate})`);
169
+ }
170
+ }
171
+ catch { /* not JSON, skip */ }
172
+ }
173
+ }
174
+ }
175
+ catch (err) {
176
+ console.error('[dario] Proxy error:', sanitizeError(err));
177
+ res.writeHead(502, { 'Content-Type': 'application/json' });
178
+ res.end(JSON.stringify({ error: 'Proxy error', message: sanitizeError(err) }));
179
+ }
180
+ });
181
+ server.listen(port, LOCALHOST, () => {
182
+ const oauthLine = `OAuth: ${status.status} (expires in ${status.expiresIn})`;
183
+ console.log('');
184
+ console.log(` dario v1.0.0 — http://localhost:${port}`);
185
+ console.log('');
186
+ console.log(' Your Claude subscription is now an API.');
187
+ console.log('');
188
+ console.log(' Usage:');
189
+ console.log(` ANTHROPIC_BASE_URL=http://localhost:${port}`);
190
+ console.log(' ANTHROPIC_API_KEY=dario');
191
+ console.log('');
192
+ console.log(` ${oauthLine}`);
193
+ console.log('');
194
+ });
195
+ // Periodic token refresh (every 15 minutes)
196
+ setInterval(async () => {
197
+ try {
198
+ const s = await getStatus();
199
+ if (s.status === 'expiring') {
200
+ console.log('[dario] Token expiring, refreshing...');
201
+ await getAccessToken(); // triggers refresh
202
+ }
203
+ }
204
+ catch (err) {
205
+ console.error('[dario] Background refresh error:', err instanceof Error ? err.message : err);
206
+ }
207
+ }, 15 * 60 * 1000);
208
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@askalf/dario",
3
+ "version": "1.0.0",
4
+ "description": "Use your Claude subscription as an API. Two commands, no API key. OAuth bridge for Claude Max/Pro.",
5
+ "type": "module",
6
+ "bin": {
7
+ "dario": "./dist/cli.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "prepublishOnly": "npm run build",
25
+ "start": "node dist/cli.js",
26
+ "dev": "tsx src/cli.ts"
27
+ },
28
+ "keywords": [
29
+ "claude",
30
+ "anthropic",
31
+ "oauth",
32
+ "proxy",
33
+ "api",
34
+ "bridge",
35
+ "subscription",
36
+ "claude-max",
37
+ "claude-pro",
38
+ "llm",
39
+ "ai",
40
+ "cli",
41
+ "developer-tools"
42
+ ],
43
+ "author": "askalf",
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/askalf/dario.git"
48
+ },
49
+ "homepage": "https://github.com/askalf/dario",
50
+ "bugs": {
51
+ "url": "https://github.com/askalf/dario/issues"
52
+ },
53
+ "engines": {
54
+ "node": ">=18.0.0"
55
+ },
56
+ "dependencies": {
57
+ "@anthropic-ai/sdk": "^0.39.0"
58
+ },
59
+ "devDependencies": {
60
+ "typescript": "^5.7.0",
61
+ "tsx": "^4.19.0",
62
+ "@types/node": "^22.0.0"
63
+ }
64
+ }