claude-autorouter 0.2.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.
@@ -0,0 +1,208 @@
1
+ // Published standard, global API prices in cents per million tokens. These are
2
+ // API-equivalent estimates, not subscription charges. Exact IDs only: an
3
+ // unfamiliar model must not inherit another model's price from its name.
4
+ // https://platform.claude.com/docs/en/about-claude/pricing (2026-09-29)
5
+ const HAIKU_45 = { input: 100, output: 500, write5m: 125, write1h: 200, read: 10 };
6
+ const SONNET_5 = { input: 200, output: 1000, write5m: 250, write1h: 400, read: 20 };
7
+ const OPUS_5 = { input: 500, output: 2500, write5m: 625, write1h: 1000, read: 50 };
8
+ const OPUS_55 = { input: 400, output: 2000, write5m: 500, write1h: 800, read: 20 };
9
+ const PRICES = new Map([
10
+ ['claude-haiku-4-5', HAIKU_45], ['claude-haiku-4-5-20251001', HAIKU_45],
11
+ ['claude-sonnet-5', SONNET_5], ['claude-sonnet-5-5', SONNET_5],
12
+ ['claude-opus-5', OPUS_5], ['claude-opus-5-5', OPUS_55],
13
+ ]);
14
+ const OPUS_MODELS = new Set(['claude-opus-5', 'claude-opus-5-5']);
15
+ const EVENTS = new Set(['request_start', 'route', 'upstream_response', 'upstream_model', 'upstream_usage',
16
+ 'upstream_error', 'request_complete', 'request_error', 'request_cancelled']);
17
+ const MAX_SESSIONS = 100;
18
+ const MAX_INFLIGHT = 1000;
19
+ const MAX_HISTORY = 10000;
20
+ const identifier = value => typeof value === 'string' && /^[\w.:-]{1,200}$/.test(value) ? value : undefined;
21
+ const count = value => Number.isSafeInteger(value) && value >= 0;
22
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
23
+
24
+ function standardPricing(context, allowUnavailableGeo = false) {
25
+ if (context === undefined) return true;
26
+ if (!object(context)) return false;
27
+ return (context.pricing_unsupported === undefined || context.pricing_unsupported === false)
28
+ && (context.unsupported === undefined || context.unsupported === false)
29
+ && (context.speed === undefined || context.speed === 'standard')
30
+ && (context.inference_geo === undefined || context.inference_geo === 'global'
31
+ || (allowUnavailableGeo && context.inference_geo === 'not_available'))
32
+ && (context.service_tier === undefined || ['auto', 'standard_only', 'standard'].includes(context.service_tier));
33
+ }
34
+
35
+ function normalizeUsage(usage, prices) {
36
+ // Haiku 4.5 predates inference geography controls: Anthropic reports
37
+ // "not_available" and standard pricing applies. Do not extend this exception
38
+ // to newer models, whose unknown geography could carry a different rate.
39
+ // https://platform.claude.com/docs/en/manage-claude/data-residency#pricing
40
+ if (!object(usage) || !count(usage.input_tokens) || !count(usage.output_tokens)
41
+ || !standardPricing(usage, prices === HAIKU_45)) return;
42
+ const read = usage.cache_read_input_tokens ?? 0;
43
+ // Missing counters are optional; explicit null or malformed counters are not.
44
+ if (!count(read) || usage.cache_read_input_tokens === null || usage.cache_creation_input_tokens === null) return;
45
+ const creation = usage.cache_creation_input_tokens;
46
+ if (creation !== undefined && !count(creation)) return;
47
+ let write5m = 0, write1h = 0;
48
+ if (usage.cache_creation !== undefined) {
49
+ if (!object(usage.cache_creation)) return;
50
+ write5m = usage.cache_creation.ephemeral_5m_input_tokens ?? 0;
51
+ write1h = usage.cache_creation.ephemeral_1h_input_tokens ?? 0;
52
+ if (!count(write5m) || !count(write1h)
53
+ || usage.cache_creation.ephemeral_5m_input_tokens === null || usage.cache_creation.ephemeral_1h_input_tokens === null) return;
54
+ if (!count(write5m + write1h) || (creation !== undefined && creation !== write5m + write1h)) return;
55
+ } else if (creation > 0) {
56
+ // The aggregate alone cannot distinguish a five-minute from a one-hour
57
+ // cache write, so it cannot establish the price.
58
+ return;
59
+ }
60
+ return { input: usage.input_tokens, output: usage.output_tokens, read, write5m, write1h };
61
+ }
62
+
63
+ function cost(usage, prices) {
64
+ // Integer arithmetic avoids floating-point drift over a long session. A unit
65
+ // here is 1/100,000,000 of a dollar (one cent per million tokens).
66
+ return Object.keys(usage).reduce((total, key) => total + BigInt(usage[key]) * BigInt(prices[key]), 0n);
67
+ }
68
+
69
+ export function createSavingsTracker({ baselineModel = 'claude-opus-5-5' } = {}) {
70
+ const baseline = OPUS_MODELS.has(baselineModel) ? PRICES.get(baselineModel) : undefined;
71
+ const baselineName = typeof baselineModel === 'string' && /^[\w.:/-]{1,120}$/.test(baselineModel) ? baselineModel : 'unknown';
72
+ const sessions = new Map();
73
+ const inflight = new Map();
74
+ const settled = new Map();
75
+ const evictedSessions = new Set();
76
+ let historyPartial = false;
77
+
78
+ function remember(key) {
79
+ settled.set(key, true);
80
+ while (settled.size > MAX_HISTORY) {
81
+ settled.delete(settled.keys().next().value);
82
+ historyPartial = true;
83
+ }
84
+ }
85
+
86
+ function finish(key, failed = false, partial = false) {
87
+ const request = inflight.get(key);
88
+ if (!request) return;
89
+ inflight.delete(key);
90
+ remember(key);
91
+ const session = sessions.get(request.sessionId);
92
+ if (!session) return;
93
+ if (partial) session.partial = true;
94
+ if (failed || request.failed || request.invalid || !request.prices || !request.usage || !baseline) {
95
+ session.unpriced_requests++;
96
+ return;
97
+ }
98
+ session.actual += cost(request.usage, request.prices);
99
+ session.baseline += cost(request.usage, baseline);
100
+ session.requests++;
101
+ }
102
+
103
+ function sessionFor(sessionId) {
104
+ let session = sessions.get(sessionId);
105
+ if (session) {
106
+ sessions.delete(sessionId);
107
+ sessions.set(sessionId, session);
108
+ return session;
109
+ }
110
+ session = { actual: 0n, baseline: 0n, requests: 0, unpriced_requests: 0,
111
+ partial: historyPartial || evictedSessions.has(sessionId) };
112
+ sessions.set(sessionId, session);
113
+ while (sessions.size > MAX_SESSIONS) {
114
+ const oldest = sessions.keys().next().value;
115
+ sessions.delete(oldest);
116
+ evictedSessions.add(oldest);
117
+ for (const [key, request] of inflight) {
118
+ if (request.sessionId === oldest) { inflight.delete(key); remember(key); }
119
+ }
120
+ if (evictedSessions.size > MAX_HISTORY) {
121
+ evictedSessions.delete(evictedSessions.values().next().value);
122
+ historyPartial = true;
123
+ }
124
+ }
125
+ return session;
126
+ }
127
+
128
+ function update(event) {
129
+ try {
130
+ if (!event || !EVENTS.has(event.event)) return;
131
+ const sessionId = event.session_id == null || event.session_id === '' ? '' : identifier(event.session_id);
132
+ const requestId = identifier(event.request_id);
133
+ if (sessionId === undefined || requestId === undefined) return;
134
+ const key = `${sessionId}\0${requestId}`;
135
+ if (event.event === 'request_start') {
136
+ if (inflight.has(key) || settled.has(key)) return;
137
+ sessionFor(sessionId);
138
+ while (inflight.size >= MAX_INFLIGHT) finish(inflight.keys().next().value, true, true);
139
+ inflight.set(key, { sessionId, invalid: !standardPricing(event.pricing_context) });
140
+ return;
141
+ }
142
+ const request = inflight.get(key);
143
+ if (!request) return;
144
+ switch (event.event) {
145
+ case 'route':
146
+ if (!standardPricing(event.pricing_context)) request.invalid = true;
147
+ break;
148
+ case 'upstream_response':
149
+ if (!Number.isInteger(event.status) || event.status < 200 || event.status >= 300) request.failed = true;
150
+ break;
151
+ case 'upstream_model': {
152
+ const prices = PRICES.get(event.model);
153
+ if (!prices || (request.prices && request.prices !== prices)) request.invalid = true;
154
+ request.prices = prices;
155
+ break;
156
+ }
157
+ case 'upstream_usage': {
158
+ const usage = normalizeUsage(event.usage, request.prices);
159
+ if (!usage || !standardPricing(event.pricing_context)) request.invalid = true;
160
+ else if (request.usage && Object.keys(usage).some(key => usage[key] !== request.usage[key])) request.invalid = true;
161
+ else request.usage = usage;
162
+ break;
163
+ }
164
+ case 'upstream_error':
165
+ request.failed = true;
166
+ break;
167
+ case 'request_complete':
168
+ finish(key);
169
+ break;
170
+ case 'request_error':
171
+ case 'request_cancelled':
172
+ finish(key, true);
173
+ break;
174
+ }
175
+ } catch {
176
+ // Telemetry must never interfere with inference. A malformed event must
177
+ // not make partially understood usage look billable.
178
+ try {
179
+ const sessionId = event?.session_id == null ? '' : identifier(event.session_id);
180
+ const requestId = identifier(event?.request_id);
181
+ const request = inflight.get(`${sessionId}\0${requestId}`);
182
+ if (request) request.invalid = true;
183
+ } catch {}
184
+ }
185
+ }
186
+
187
+ function snapshot() {
188
+ return Object.fromEntries([...sessions].map(([sessionId, session]) => {
189
+ const saved = session.baseline - session.actual;
190
+ return [sessionId, {
191
+ baseline_model: baselineName,
192
+ actual_usd: Number(session.actual) / 100000000,
193
+ baseline_usd: Number(session.baseline) / 100000000,
194
+ saved_usd: Number(saved) / 100000000,
195
+ percent: session.baseline === 0n ? 0 : Number(saved) / Number(session.baseline) * 100,
196
+ requests: session.requests,
197
+ unpriced_requests: session.unpriced_requests,
198
+ ...(session.partial || historyPartial ? { partial: true } : {}),
199
+ }];
200
+ }));
201
+ }
202
+
203
+ function clear() {
204
+ sessions.clear(); inflight.clear(); settled.clear(); evictedSessions.clear(); historyPartial = false;
205
+ }
206
+
207
+ return { update, snapshot, clear };
208
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,215 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { Router } from './router.mjs';
6
+ import { LOCAL_AUTH_HEADER, isSubscriptionRequest } from './auth.mjs';
7
+ import { prepareRequest } from './model-request.mjs';
8
+ import { createResponseObserver } from './response-observer.mjs';
9
+ import { createTokenCounter } from './token-counter.mjs';
10
+
11
+ function cleanHeaders(headers) {
12
+ const blocked = new Set(['host', 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length']);
13
+ for (const name of String(headers.connection ?? '').split(',')) blocked.add(name.trim().toLowerCase());
14
+ return Object.fromEntries(Object.entries(headers).filter(([name, value]) => value !== undefined && !blocked.has(name.toLowerCase())));
15
+ }
16
+
17
+ function authorized(req, config) {
18
+ const credential = config.authMode === 'subscription'
19
+ ? req.headers[LOCAL_AUTH_HEADER]
20
+ : req.headers['x-api-key'] ?? req.headers.authorization?.replace(/^Bearer /i, '');
21
+ if (typeof credential !== 'string') return false;
22
+ const actual = Buffer.from(credential);
23
+ const expected = Buffer.from(config.localToken);
24
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
25
+ }
26
+
27
+ function readBody(req, limit) {
28
+ return new Promise((resolve, reject) => {
29
+ let bytes = 0;
30
+ const chunks = [];
31
+ req.on('data', chunk => {
32
+ bytes += chunk.length;
33
+ if (bytes > limit) {
34
+ chunks.length = 0;
35
+ reject(Object.assign(new Error('Request body too large'), { status: 413 }));
36
+ } else chunks.push(chunk);
37
+ });
38
+ req.on('end', () => resolve(Buffer.concat(chunks)));
39
+ req.on('error', reject);
40
+ req.on('aborted', () => reject(new Error('Client disconnected')));
41
+ });
42
+ }
43
+
44
+ function jsonError(res, status, message) {
45
+ if (res.headersSent || res.destroyed) { res.destroy(); return; }
46
+ res.writeHead(status, { 'content-type': 'application/json' });
47
+ res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message } }));
48
+ }
49
+
50
+ function upstreamHeaders(incoming, config) {
51
+ const headers = cleanHeaders(incoming);
52
+ delete headers[LOCAL_AUTH_HEADER];
53
+ delete headers.cookie;
54
+ // Negotiate an uncompressed stream so the model ID can be observed without
55
+ // altering any response bytes or buffering generation output.
56
+ headers['accept-encoding'] = 'identity';
57
+ if (config.authMode === 'subscription') {
58
+ // Do not cache or refresh this credential; Claude Code owns its lifecycle.
59
+ // Keep Authorization and anthropic-beta intact, including on token refresh.
60
+ delete headers['x-api-key'];
61
+ } else {
62
+ delete headers.authorization;
63
+ headers['x-api-key'] = config.anthropicKey;
64
+ }
65
+ return headers;
66
+ }
67
+
68
+ async function forward(url, req, res, body, config, signal, log, status) {
69
+ const headers = upstreamHeaders(req.headers, config);
70
+ if (body) headers['content-length'] = String(body.length);
71
+ const transport = url.protocol === 'https:' ? https : http;
72
+ const upstream = await new Promise((resolve, reject) => {
73
+ const outgoing = transport.request(url, { method: req.method, headers, signal }, resolve);
74
+ outgoing.on('error', reject);
75
+ outgoing.end(body);
76
+ });
77
+ // Native HTTP streams retain compression and SSE bytes verbatim.
78
+ log({ event: 'upstream_response', status: upstream.statusCode });
79
+ status('upstream_response', { status: upstream.statusCode });
80
+ // An upstream disconnect can make pipeline destroy the downstream response.
81
+ // Record it before that close is mistaken for a user cancellation.
82
+ upstream.once('aborted', () => {
83
+ if (!signal.aborted) status('request_error', { status: 502 });
84
+ });
85
+ res.writeHead(upstream.statusCode, cleanHeaders(upstream.headers));
86
+ try {
87
+ if (!upstream.headers['content-encoding'] || upstream.headers['content-encoding'] === 'identity') {
88
+ const observer = createResponseObserver({
89
+ contentType: upstream.headers['content-type'],
90
+ onModel: ({ model }) => { log({ event: 'upstream_model', model }); status('upstream_model', { model }); },
91
+ onError: ({ error_type }) => { log({ event: 'upstream_error', error_type }); status('upstream_error', { error_type }); },
92
+ onUsage: ({ usage }) => { if (upstream.statusCode >= 200 && upstream.statusCode < 300) status('upstream_usage', { usage }); },
93
+ });
94
+ await pipeline(upstream, observer, res, { signal });
95
+ } else {
96
+ await pipeline(upstream, res, { signal });
97
+ }
98
+ } catch (error) {
99
+ if (!signal.aborted || signal.reason?.name === 'TimeoutError') status('request_error', { status: 502 });
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ export function createRouterServer(config, { router = new Router(config), tokenCounter = createTokenCounter(config), log = entry => process.stderr.write(`${JSON.stringify(entry)}\n`), onStatus = () => {} } = {}) {
105
+ if (!config.localToken || config.localToken.length < 16) throw new Error('AUTOROUTER_TOKEN must contain at least 16 characters');
106
+ const server = http.createServer(async (req, res) => {
107
+ const controller = new AbortController();
108
+ let context;
109
+ let finished = false;
110
+ const status = (event, fields = {}) => {
111
+ if (!context || finished) return;
112
+ if (['request_complete', 'request_error', 'request_cancelled'].includes(event)) finished = true;
113
+ // Optional observability must never delay or fail an inference request.
114
+ try { Promise.resolve(onStatus({ ...context, event, ...fields })).catch(() => {}); } catch {}
115
+ };
116
+ const rejectRequest = (code, message) => {
117
+ status('request_error', { status: code });
118
+ return jsonError(res, code, message);
119
+ };
120
+ res.on('close', () => { if (!res.writableFinished) controller.abort(); });
121
+ req.on('aborted', () => controller.abort());
122
+ try {
123
+ if (req.headers.origin) return jsonError(res, 403, 'Browser requests are not supported');
124
+ if (!authorized(req, config)) return jsonError(res, 401, 'Invalid local router credential');
125
+ // Use a fixed upstream and an allowlist; never interpret a client URL as
126
+ // an upstream origin, or turn this into an open forwarding proxy.
127
+ const url = new URL(req.url, 'http://127.0.0.1');
128
+ if (req.method === 'GET' && url.pathname === '/health') {
129
+ res.writeHead(200, { 'content-type': 'application/json' });
130
+ return res.end(JSON.stringify({ status: 'ok' }));
131
+ }
132
+ if (req.method === 'HEAD' && url.pathname === '/api/hello') { res.writeHead(200); return res.end(); }
133
+ const inference = req.method === 'POST' && url.pathname === '/v1/messages';
134
+ const countTokens = req.method === 'POST' && url.pathname === '/v1/messages/count_tokens';
135
+ const models = req.method === 'GET' && /^\/v1\/models(?:\/[^/]+)?$/.test(url.pathname);
136
+ if (!inference && !countTokens && !models) return jsonError(res, 404, 'Unsupported endpoint');
137
+ if (inference) {
138
+ const hint = name => typeof req.headers[name] === 'string' ? req.headers[name] : undefined;
139
+ context = {
140
+ request_id: randomUUID(),
141
+ session_id: hint('x-claude-code-session-id'), agent_id: hint('x-claude-code-agent-id'),
142
+ prompt_id: hint('x-claude-code-prompt-id'), request_class: hint('x-claude-code-request-class'),
143
+ };
144
+ status('request_start');
145
+ }
146
+ if (config.authMode === 'subscription' && !isSubscriptionRequest(req.headers)) {
147
+ return rejectRequest(401, 'Subscription mode requires Claude Code OAuth authentication and its OAuth beta header, without an API key. Sign in with claude auth login and remove conflicting API-key, auth-token, custom-header, or apiKeyHelper settings.');
148
+ }
149
+ if (req.headers['content-encoding'] && req.headers['content-encoding'] !== 'identity') return rejectRequest(415, 'Compressed request bodies are not supported');
150
+ if (Number(req.headers['content-length']) > config.maxBodyBytes) return rejectRequest(413, 'Request body too large');
151
+ let body;
152
+ if (inference || countTokens) {
153
+ body = await readBody(req, config.maxBodyBytes);
154
+ let parsed;
155
+ try { parsed = JSON.parse(body); } catch { return rejectRequest(400, 'Invalid JSON body'); }
156
+ if (!parsed || typeof parsed.model !== 'string' || !Array.isArray(parsed.messages) || parsed.messages.some(m => !m || !['user', 'assistant', 'system'].includes(m.role) || !(typeof m.content === 'string' || (Array.isArray(m.content) && m.content.every(b => b && typeof b.type === 'string'))))) {
157
+ log({ event: 'invalid_request_shape', model_type: typeof parsed?.model, messages_type: Array.isArray(parsed?.messages) ? 'array' : typeof parsed?.messages,
158
+ messages: Array.isArray(parsed?.messages) ? parsed.messages.slice(0, 10).map(m => ({
159
+ role: ['user', 'assistant', 'system'].includes(m?.role) ? m.role : typeof m?.role,
160
+ content_type: Array.isArray(m?.content) ? 'array' : typeof m?.content,
161
+ blocks: Array.isArray(m?.content) ? m.content.slice(0, 10).map(b => ({ value_type: b === null ? 'null' : typeof b, type_type: typeof b?.type })) : undefined,
162
+ })) : undefined });
163
+ return rejectRequest(400, 'Expected a model and Messages API messages');
164
+ }
165
+ if (inference) {
166
+ const decision = await router.route(parsed, {
167
+ signal: controller.signal,
168
+ scope: JSON.stringify([req.headers['x-claude-code-session-id'], req.headers['x-claude-code-agent-id']]),
169
+ promptId: req.headers['x-claude-code-prompt-id'],
170
+ requestClass: req.headers['x-claude-code-request-class'],
171
+ countTokens: (body, model) => tokenCounter(body, model, {
172
+ headers: upstreamHeaders(req.headers, config), signal: controller.signal, search: url.search,
173
+ }),
174
+ });
175
+ if (controller.signal.aborted) { status('request_cancelled'); return; }
176
+ const prepared = prepareRequest(parsed, decision.model);
177
+ body = Buffer.from(JSON.stringify(prepared.request));
178
+ log({ event: 'route', requested_model: parsed.model, ...decision, request_adjustments: prepared.adjustments });
179
+ const { model, source, evaluator, reason, latency_ms, classifier_error, classifier_status, classified_tier, context_check, counted_input_tokens } = decision;
180
+ const pricingValue = (field, allowed, fallback) => prepared.request[field] === undefined ? fallback
181
+ : allowed.includes(prepared.request[field]) ? prepared.request[field] : 'unknown';
182
+ const pricing_context = {
183
+ speed: pricingValue('speed', ['standard', 'fast'], 'standard'),
184
+ inference_geo: pricingValue('inference_geo', ['global', 'us'], 'global'),
185
+ service_tier: pricingValue('service_tier', ['auto', 'standard_only'], 'auto'),
186
+ };
187
+ if (prepared.request.fallbacks != null || prepared.request.fallback_credit_token != null
188
+ || (Array.isArray(prepared.request.tools) && prepared.request.tools.some(tool => typeof tool?.type === 'string' && /^advisor(?:_|$)/.test(tool.type)))) pricing_context.pricing_unsupported = true;
189
+ status('route', { requested_model: parsed.model, model, source, evaluator, reason, latency_ms, classifier_error, classifier_status,
190
+ classified_tier, context_check, counted_input_tokens, pricing_context });
191
+ }
192
+ }
193
+ if (controller.signal.aborted) { status('request_cancelled'); return; }
194
+ const target = new URL(`${config.upstream}${url.pathname}${url.search}`);
195
+ await forward(target, req, res, body, config, AbortSignal.any([controller.signal, AbortSignal.timeout(config.upstreamTimeoutMs)]), log, status);
196
+ status('request_complete');
197
+ } catch (error) {
198
+ if (controller.signal.aborted) { status('request_cancelled'); return; }
199
+ // Error strings from providers can contain request data. Keep logs local
200
+ // and metadata-only, including on error paths.
201
+ log({ event: 'proxy_error', status: error.status ?? 502 });
202
+ rejectRequest(error.status ?? 502, error.status === 413 ? 'Request body too large' : 'Router could not complete the upstream request');
203
+ }
204
+ });
205
+ server.requestTimeout = 30000;
206
+ server.headersTimeout = 10000;
207
+ return server;
208
+ }
209
+
210
+ export function listen(server, port) {
211
+ return new Promise((resolve, reject) => {
212
+ server.once('error', reject);
213
+ server.listen(port, '127.0.0.1', () => { server.removeListener('error', reject); resolve(server.address()); });
214
+ });
215
+ }
@@ -0,0 +1,88 @@
1
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join, resolve } from 'node:path';
4
+
5
+ // Claude executes statusLine.command through a shell, including when paths have
6
+ // spaces, quotes, dollar signs, or backticks. Never interpolate them unquoted.
7
+ export function shellQuote(value) {
8
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
9
+ }
10
+
11
+ export function statusLineSettings() {
12
+ const script = fileURLToPath(new URL('../bin/statusline.mjs', import.meta.url));
13
+ return { type: 'command', command: `${shellQuote(process.execPath)} ${shellQuote(script)}`, padding: 0, refreshInterval: 1 };
14
+ }
15
+
16
+ function rebasePermissionRules(settings, sourceDirectory) {
17
+ if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) return settings;
18
+ // Read/Edit /path patterns anchor at dirname(--settings file), or cwd for
19
+ // inline settings. Preserve that anchor when moving the settings to temp.
20
+ // Absolute permission patterns use //path, unlike ordinary filesystem paths.
21
+ // https://code.claude.com/docs/en/permissions#read-and-edit
22
+ let anchor = sourceDirectory;
23
+ if (process.platform === 'win32') anchor = anchor.replaceAll('\\', '/').replace(/^([a-z]):/i, (_, drive) => `/${drive.toLowerCase()}`);
24
+ anchor = anchor.replace(/\/$/, '').replace(/[\\*?\[\]]/g, '\\$&');
25
+ const permissions = { ...settings.permissions };
26
+ for (const kind of ['allow', 'ask', 'deny']) {
27
+ if (!Array.isArray(permissions[kind])) continue;
28
+ permissions[kind] = permissions[kind].map(rule => {
29
+ const match = typeof rule === 'string' && /^(Read|Edit)\((\/(?!\/)[\s\S]*)\)$/.exec(rule);
30
+ return match ? `${match[1]}(/${anchor}${match[2]})` : rule;
31
+ });
32
+ }
33
+ return { ...settings, permissions };
34
+ }
35
+
36
+ function checkSandboxPaths(settings) {
37
+ // Sandbox paths use conventional /absolute and ~/home syntax, not the
38
+ // Read/Edit // syntax. Documentation specifies source-dependent relative
39
+ // paths but does not define the --settings file/inline anchor explicitly.
40
+ // Keep original settings intact by declining the overlay for that case.
41
+ const paths = [];
42
+ for (const key of ['allowRead', 'allowWrite', 'denyRead', 'denyWrite']) {
43
+ const values = settings.sandbox?.filesystem?.[key];
44
+ if (Array.isArray(values)) paths.push(...values);
45
+ }
46
+ if (Array.isArray(settings.sandbox?.credentials?.files)) {
47
+ paths.push(...settings.sandbox.credentials.files.map(entry => entry?.path));
48
+ }
49
+ if (paths.some(path => typeof path === 'string' && !path.startsWith('/') && !path.startsWith('~/'))) {
50
+ throw new Error('Cannot safely relocate source-relative sandbox paths; keep the original Claude settings');
51
+ }
52
+ }
53
+
54
+ export function addStatusLineSettings(args, directory) {
55
+ const forwarded = [];
56
+ let supplied;
57
+ for (let i = 0; i < args.length; i++) {
58
+ if (args[i] === '--') { forwarded.push(...args.slice(i)); break; }
59
+ if (args[i] === '--settings') {
60
+ supplied = args[++i];
61
+ if (!supplied || supplied.startsWith('--')) throw new Error('--settings requires a JSON object or settings file');
62
+ } else if (args[i].startsWith('--settings=')) supplied = args[i].slice('--settings='.length);
63
+ else forwarded.push(args[i]);
64
+ }
65
+ let settings = {};
66
+ let sourceDirectory = process.cwd();
67
+ if (supplied !== undefined) {
68
+ try {
69
+ let json = supplied;
70
+ if (!supplied.trimStart().startsWith('{')) {
71
+ const stat = statSync(supplied);
72
+ if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error();
73
+ json = readFileSync(supplied, 'utf8');
74
+ sourceDirectory = dirname(resolve(supplied));
75
+ }
76
+ settings = JSON.parse(json);
77
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error();
78
+ } catch {
79
+ throw new Error('Could not read --settings as a JSON object; set AUTOROUTER_STATUSLINE=0 to pass it directly to Claude');
80
+ }
81
+ }
82
+ // Use a private temporary file instead of putting user settings (which may
83
+ // include sensitive env values) into process arguments. Saved files are intact.
84
+ checkSandboxPaths(settings);
85
+ const path = join(directory, 'claude-settings.json');
86
+ writeFileSync(path, JSON.stringify({ ...rebasePermissionRules(settings, sourceDirectory), statusLine: statusLineSettings() }), { mode: 0o600 });
87
+ return ['--settings', path, ...forwarded];
88
+ }