@stage-labs/metro 0.1.0-beta.27 → 0.1.0-beta.29

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,120 @@
1
+ const PRELUDE_BYTES = 12;
2
+ const CRC_BYTES = 4;
3
+ const HEADER_TRUE = 0;
4
+ const HEADER_FALSE = 1;
5
+ const HEADER_BYTE = 2;
6
+ const HEADER_SHORT = 3;
7
+ const HEADER_INT = 4;
8
+ const HEADER_LONG = 5;
9
+ const HEADER_BYTES = 6;
10
+ const HEADER_STRING = 7;
11
+ const HEADER_TIMESTAMP = 8;
12
+ const HEADER_UUID = 9;
13
+ const FIXED_WIDTH = {
14
+ [HEADER_TRUE]: 0,
15
+ [HEADER_FALSE]: 0,
16
+ [HEADER_BYTE]: 1,
17
+ [HEADER_SHORT]: 2,
18
+ [HEADER_INT]: 4,
19
+ [HEADER_LONG]: 8,
20
+ [HEADER_TIMESTAMP]: 8,
21
+ [HEADER_UUID]: 16,
22
+ };
23
+ function readHeaderValue(buf, at, type) {
24
+ if (type === HEADER_BYTES || type === HEADER_STRING) {
25
+ const len = buf.readUInt16BE(at);
26
+ const raw = buf.subarray(at + 2, at + 2 + len);
27
+ const value = type === HEADER_STRING ? raw.toString('utf8') : raw.toString('base64');
28
+ return { value, next: at + 2 + len };
29
+ }
30
+ const width = FIXED_WIDTH[type];
31
+ if (width === undefined)
32
+ throw new Error(`eventstream: unknown header type ${String(type)}`);
33
+ if (type === HEADER_TRUE)
34
+ return { value: 'true', next: at };
35
+ if (type === HEADER_FALSE)
36
+ return { value: 'false', next: at };
37
+ return { value: buf.subarray(at, at + width).toString('hex'), next: at + width };
38
+ }
39
+ function readHeaders(buf) {
40
+ const out = {};
41
+ let at = 0;
42
+ while (at < buf.length) {
43
+ const nameLen = buf.readUInt8(at);
44
+ const name = buf.subarray(at + 1, at + 1 + nameLen).toString('utf8');
45
+ const type = buf.readUInt8(at + 1 + nameLen);
46
+ const parsed = readHeaderValue(buf, at + 2 + nameLen, type);
47
+ out[name] = parsed.value;
48
+ at = parsed.next;
49
+ }
50
+ return out;
51
+ }
52
+ export function decodeFrame(buf, offset) {
53
+ if (buf.length - offset < PRELUDE_BYTES)
54
+ return null;
55
+ const total = buf.readUInt32BE(offset);
56
+ const headersLen = buf.readUInt32BE(offset + 4);
57
+ if (total < PRELUDE_BYTES + CRC_BYTES || headersLen > total)
58
+ throw new Error('eventstream: malformed frame prelude');
59
+ if (buf.length - offset < total)
60
+ return null;
61
+ const headersStart = offset + PRELUDE_BYTES;
62
+ const payloadStart = headersStart + headersLen;
63
+ const payloadEnd = offset + total - CRC_BYTES;
64
+ return {
65
+ message: {
66
+ headers: readHeaders(buf.subarray(headersStart, payloadStart)),
67
+ payload: Buffer.from(buf.subarray(payloadStart, payloadEnd)),
68
+ },
69
+ end: offset + total,
70
+ };
71
+ }
72
+ export class EventStreamDecoder {
73
+ pending = Buffer.alloc(0);
74
+ push(chunk) {
75
+ this.pending =
76
+ this.pending.length === 0 ? chunk : Buffer.concat([this.pending, chunk]);
77
+ const out = [];
78
+ let offset = 0;
79
+ for (;;) {
80
+ const frame = decodeFrame(this.pending, offset);
81
+ if (frame === null)
82
+ break;
83
+ out.push(frame.message);
84
+ offset = frame.end;
85
+ }
86
+ this.pending = this.pending.subarray(offset);
87
+ return out;
88
+ }
89
+ leftover() {
90
+ return this.pending.length;
91
+ }
92
+ }
93
+ function encodeHeader(name, value) {
94
+ const nameBuf = Buffer.from(name, 'utf8');
95
+ const valueBuf = Buffer.from(value, 'utf8');
96
+ const out = Buffer.alloc(1 + nameBuf.length + 1 + 2 + valueBuf.length);
97
+ let at = 0;
98
+ out.writeUInt8(nameBuf.length, at);
99
+ at += 1;
100
+ nameBuf.copy(out, at);
101
+ at += nameBuf.length;
102
+ out.writeUInt8(HEADER_STRING, at);
103
+ at += 1;
104
+ out.writeUInt16BE(valueBuf.length, at);
105
+ at += 2;
106
+ valueBuf.copy(out, at);
107
+ return out;
108
+ }
109
+ export function encodeFrame(headers, payload) {
110
+ const headerBuf = Buffer.concat(Object.entries(headers).map(([name, value]) => encodeHeader(name, value)));
111
+ const total = PRELUDE_BYTES + headerBuf.length + payload.length + CRC_BYTES;
112
+ const out = Buffer.alloc(total);
113
+ out.writeUInt32BE(total, 0);
114
+ out.writeUInt32BE(headerBuf.length, 4);
115
+ out.writeUInt32BE(0, 8);
116
+ headerBuf.copy(out, PRELUDE_BYTES);
117
+ payload.copy(out, PRELUDE_BYTES + headerBuf.length);
118
+ out.writeUInt32BE(0, total - CRC_BYTES);
119
+ return out;
120
+ }
@@ -0,0 +1,296 @@
1
+ import { createServer, } from 'node:http';
2
+ import { EventStreamDecoder } from './bedrock-eventstream.js';
3
+ export class BedrockConfigError extends Error {
4
+ }
5
+ export class ProxyError extends Error {
6
+ status;
7
+ kind;
8
+ constructor(status, kind, message) {
9
+ super(message);
10
+ this.status = status;
11
+ this.kind = kind;
12
+ }
13
+ }
14
+ const ANTHROPIC_VERSION = 'bedrock-2023-05-31';
15
+ const BODY_MAX = 64 * 1024 * 1024;
16
+ const MESSAGES_PATH = '/v1/messages';
17
+ const COUNT_PATH = '/v1/messages/count_tokens';
18
+ function firstEnv(env, names) {
19
+ for (const name of names) {
20
+ const value = env[name]?.trim() ?? '';
21
+ if (value !== '')
22
+ return value;
23
+ }
24
+ return '';
25
+ }
26
+ export function bedrockConfigFromEnv(env = process.env) {
27
+ const bearerToken = firstEnv(env, ['AWS_BEARER_TOKEN_BEDROCK']);
28
+ if (bearerToken === '')
29
+ throw new BedrockConfigError('AWS_BEARER_TOKEN_BEDROCK is not set. Create an Amazon Bedrock API key in the AWS console (Bedrock → API keys) and export it.');
30
+ const region = firstEnv(env, ['METRO_BEDROCK_REGION', 'AWS_REGION', 'AWS_DEFAULT_REGION']);
31
+ if (region === '')
32
+ throw new BedrockConfigError('no AWS region: set AWS_REGION (or METRO_BEDROCK_REGION) to the region your Bedrock API key belongs to');
33
+ const model = firstEnv(env, ['METRO_BEDROCK_MODEL']);
34
+ return { region, bearerToken, model: model === '' ? null : model };
35
+ }
36
+ const PREFIXES = [
37
+ [/^us-gov-/, 'us-gov'],
38
+ [/^us-/, 'us'],
39
+ [/^eu-/, 'eu'],
40
+ [/^ap-/, 'apac'],
41
+ ];
42
+ export function regionPrefix(region) {
43
+ for (const [pattern, prefix] of PREFIXES)
44
+ if (pattern.test(region))
45
+ return prefix;
46
+ return 'global';
47
+ }
48
+ export function mapModel(requested, cfg) {
49
+ if (cfg.model !== null)
50
+ return cfg.model;
51
+ if (requested.includes('anthropic.'))
52
+ return requested;
53
+ return `${regionPrefix(cfg.region)}.anthropic.${requested}`;
54
+ }
55
+ export const upstreamBase = (cfg, override) => override ?? `https://bedrock-runtime.${cfg.region}.amazonaws.com`;
56
+ function splitBetas(header) {
57
+ const raw = Array.isArray(header) ? header.join(',') : (header ?? '');
58
+ return raw
59
+ .split(',')
60
+ .map((b) => b.trim())
61
+ .filter((b) => b !== '');
62
+ }
63
+ export function rewriteBody(raw, betaHeader, cfg) {
64
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
65
+ throw new ProxyError(400, 'invalid_request_error', 'body must be a JSON object');
66
+ const { model, stream, ...rest } = raw;
67
+ if (typeof model !== 'string' || model === '')
68
+ throw new ProxyError(400, 'invalid_request_error', 'model is required');
69
+ const betas = splitBetas(betaHeader);
70
+ const body = { ...rest, anthropic_version: ANTHROPIC_VERSION };
71
+ if (betas.length > 0)
72
+ body.anthropic_beta = betas;
73
+ return { modelId: mapModel(model, cfg), stream: stream === true, body, betas };
74
+ }
75
+ export function errorKind(status, errorType) {
76
+ if (status === 429 || errorType === 'ThrottlingException')
77
+ return 'rate_limit_error';
78
+ if (status === 400)
79
+ return 'invalid_request_error';
80
+ if (status === 401)
81
+ return 'authentication_error';
82
+ if (status === 403)
83
+ return 'permission_error';
84
+ if (status === 404)
85
+ return 'not_found_error';
86
+ if (status === 503)
87
+ return 'overloaded_error';
88
+ return 'api_error';
89
+ }
90
+ function sendError(res, status, kind, message) {
91
+ res.writeHead(status, { 'content-type': 'application/json' });
92
+ res.end(JSON.stringify({ type: 'error', error: { type: kind, message } }));
93
+ }
94
+ async function readBody(req) {
95
+ const chunks = [];
96
+ let total = 0;
97
+ for await (const chunk of req) {
98
+ const buf = chunk;
99
+ total += buf.length;
100
+ if (total > BODY_MAX)
101
+ throw new ProxyError(413, 'invalid_request_error', 'request too large');
102
+ chunks.push(buf);
103
+ }
104
+ try {
105
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
106
+ }
107
+ catch {
108
+ throw new ProxyError(400, 'invalid_request_error', 'body must be JSON');
109
+ }
110
+ }
111
+ function invokeUrl(up, modelId, action) {
112
+ return `${up.base}/model/${encodeURIComponent(modelId)}/${action}`;
113
+ }
114
+ function callBedrock(up, url, body, accept, signal) {
115
+ return fetch(url, {
116
+ method: 'POST',
117
+ headers: {
118
+ authorization: `Bearer ${up.cfg.bearerToken}`,
119
+ 'content-type': 'application/json',
120
+ accept,
121
+ },
122
+ body: JSON.stringify(body),
123
+ signal,
124
+ });
125
+ }
126
+ async function invoke(up, req, signal) {
127
+ const action = req.stream ? 'invoke-with-response-stream' : 'invoke';
128
+ const accept = req.stream ? 'application/vnd.amazon.eventstream' : 'application/json';
129
+ const url = invokeUrl(up, req.modelId, action);
130
+ const first = await callBedrock(up, url, req.body, accept, signal);
131
+ if (first.status !== 400 || req.betas.length === 0)
132
+ return first;
133
+ await first.text();
134
+ process.stderr.write(`metro bedrock: retrying without anthropic_beta [${req.betas.join(', ')}]\n`);
135
+ const stripped = Object.fromEntries(Object.entries(req.body).filter(([key]) => key !== 'anthropic_beta'));
136
+ return callBedrock(up, url, stripped, accept, signal);
137
+ }
138
+ function upstreamMessage(text) {
139
+ try {
140
+ const parsed = JSON.parse(text);
141
+ const message = parsed.message ?? parsed.Message;
142
+ if (typeof message === 'string' && message !== '')
143
+ return message;
144
+ }
145
+ catch {
146
+ return text === '' ? 'Bedrock returned no body' : text;
147
+ }
148
+ return text === '' ? 'Bedrock returned no body' : text;
149
+ }
150
+ async function relayError(upstream, res) {
151
+ const text = await upstream.text();
152
+ const kind = errorKind(upstream.status, upstream.headers.get('x-amzn-errortype'));
153
+ sendError(res, upstream.status, kind, upstreamMessage(text));
154
+ }
155
+ function eventTypeOf(event) {
156
+ try {
157
+ const parsed = JSON.parse(event);
158
+ return typeof parsed.type === 'string' ? parsed.type : 'message';
159
+ }
160
+ catch {
161
+ return 'message';
162
+ }
163
+ }
164
+ function writeEvent(res, message) {
165
+ if (message.headers[':message-type'] === 'event') {
166
+ const parsed = JSON.parse(message.payload.toString('utf8'));
167
+ if (typeof parsed.bytes !== 'string')
168
+ return;
169
+ const event = Buffer.from(parsed.bytes, 'base64').toString('utf8');
170
+ res.write(`event: ${eventTypeOf(event)}\ndata: ${event}\n\n`);
171
+ return;
172
+ }
173
+ const errorType = message.headers[':exception-type'] ?? message.headers[':error-code'] ?? null;
174
+ const body = {
175
+ type: 'error',
176
+ error: {
177
+ type: errorKind(500, errorType),
178
+ message: upstreamMessage(message.payload.toString('utf8')),
179
+ },
180
+ };
181
+ res.write(`event: error\ndata: ${JSON.stringify(body)}\n\n`);
182
+ }
183
+ async function relayStream(upstream, res) {
184
+ res.writeHead(200, {
185
+ 'content-type': 'text/event-stream',
186
+ 'cache-control': 'no-store',
187
+ connection: 'keep-alive',
188
+ });
189
+ const body = upstream.body;
190
+ if (body === null) {
191
+ res.end();
192
+ return;
193
+ }
194
+ const decoder = new EventStreamDecoder();
195
+ const reader = body.getReader();
196
+ for (;;) {
197
+ const { done, value } = await reader.read();
198
+ if (done)
199
+ break;
200
+ for (const message of decoder.push(Buffer.from(value)))
201
+ writeEvent(res, message);
202
+ }
203
+ res.end();
204
+ }
205
+ async function relayJson(upstream, res) {
206
+ const text = await upstream.text();
207
+ res.writeHead(200, { 'content-type': 'application/json' });
208
+ res.end(text);
209
+ }
210
+ async function handleMessages(req, res, up) {
211
+ const rewritten = rewriteBody(await readBody(req), req.headers['anthropic-beta'], up.cfg);
212
+ const controller = new AbortController();
213
+ res.on('close', () => {
214
+ if (!res.writableEnded)
215
+ controller.abort();
216
+ });
217
+ const upstream = await invoke(up, rewritten, controller.signal);
218
+ if (!upstream.ok) {
219
+ await relayError(upstream, res);
220
+ return;
221
+ }
222
+ if (rewritten.stream)
223
+ await relayStream(upstream, res);
224
+ else
225
+ await relayJson(upstream, res);
226
+ }
227
+ function estimateTokens(body) {
228
+ return Math.ceil(JSON.stringify(body).length / 4);
229
+ }
230
+ async function handleCount(req, res, up) {
231
+ const rewritten = rewriteBody(await readBody(req), req.headers['anthropic-beta'], up.cfg);
232
+ const controller = new AbortController();
233
+ const upstream = await callBedrock(up, invokeUrl(up, rewritten.modelId, 'count-tokens'), { input: { invokeModel: { body: JSON.stringify(rewritten.body) } } }, 'application/json', controller.signal);
234
+ const text = await upstream.text();
235
+ let counted = null;
236
+ if (upstream.ok) {
237
+ const parsed = JSON.parse(text);
238
+ if (typeof parsed.inputTokens === 'number')
239
+ counted = parsed.inputTokens;
240
+ }
241
+ res.writeHead(200, { 'content-type': 'application/json' });
242
+ res.end(JSON.stringify({ input_tokens: counted ?? estimateTokens(rewritten.body) }));
243
+ }
244
+ function presentedToken(req) {
245
+ const auth = req.headers.authorization ?? '';
246
+ if (auth.toLowerCase().startsWith('bearer '))
247
+ return auth.slice(7).trim();
248
+ const key = req.headers['x-api-key'];
249
+ return Array.isArray(key) ? (key[0] ?? '') : (key ?? '');
250
+ }
251
+ async function route(req, res, cfg, opts) {
252
+ if (presentedToken(req) !== opts.token)
253
+ throw new ProxyError(401, 'authentication_error', 'unauthorized');
254
+ const path = (req.url ?? '').split('?')[0] ?? '';
255
+ const up = { cfg, base: upstreamBase(cfg, opts.upstream) };
256
+ if (req.method === 'POST' && path === MESSAGES_PATH)
257
+ return handleMessages(req, res, up);
258
+ if (req.method === 'POST' && path === COUNT_PATH)
259
+ return handleCount(req, res, up);
260
+ throw new ProxyError(404, 'not_found_error', 'not found');
261
+ }
262
+ function failed(res, err) {
263
+ if (res.headersSent) {
264
+ res.end();
265
+ return;
266
+ }
267
+ if (err instanceof ProxyError) {
268
+ sendError(res, err.status, err.kind, err.message);
269
+ return;
270
+ }
271
+ const message = err instanceof Error ? err.message : 'proxy failure';
272
+ sendError(res, 502, 'api_error', `metro bedrock proxy: ${message}`);
273
+ }
274
+ export function startBedrockProxy(cfg, opts) {
275
+ const server = createServer((req, res) => {
276
+ route(req, res, cfg, opts).catch((err) => {
277
+ failed(res, err);
278
+ });
279
+ });
280
+ return new Promise((resolve, reject) => {
281
+ server.once('error', reject);
282
+ server.listen(opts.port ?? 0, '127.0.0.1', () => {
283
+ const address = server.address();
284
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
285
+ resolve({
286
+ port,
287
+ server,
288
+ close: () => new Promise((done) => {
289
+ server.close(() => {
290
+ done();
291
+ });
292
+ }),
293
+ });
294
+ });
295
+ });
296
+ }
@@ -0,0 +1,99 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { claudeArgs } from './claude.js';
7
+ import { bedrockConfigFromEnv, startBedrockProxy } from './bedrock-proxy.js';
8
+ export const PROVIDER_FLAGS = [
9
+ 'CLAUDE_CODE_USE_BEDROCK',
10
+ 'CLAUDE_CODE_USE_MANTLE',
11
+ 'CLAUDE_CODE_USE_VERTEX',
12
+ 'CLAUDE_CODE_USE_FOUNDRY',
13
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
14
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
15
+ 'CLAUDE_CODE_USE_GATEWAY',
16
+ ];
17
+ const CONFLICTING = [
18
+ ...PROVIDER_FLAGS,
19
+ 'ANTHROPIC_BASE_URL',
20
+ 'ANTHROPIC_API_KEY',
21
+ 'ANTHROPIC_AUTH_TOKEN',
22
+ ];
23
+ const SCRUBBED = [...PROVIDER_FLAGS, 'ANTHROPIC_API_KEY', 'AWS_BEARER_TOKEN_BEDROCK'];
24
+ export function settingsFiles(cwd = process.cwd(), env = process.env) {
25
+ const explicit = env.CLAUDE_CONFIG_DIR?.trim() ?? '';
26
+ const configDir = explicit === '' ? join(homedir(), '.claude') : explicit;
27
+ return [
28
+ join(configDir, 'settings.json'),
29
+ join(cwd, '.claude', 'settings.json'),
30
+ join(cwd, '.claude', 'settings.local.json'),
31
+ ];
32
+ }
33
+ function envBlock(file) {
34
+ if (!existsSync(file))
35
+ return {};
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
38
+ const block = parsed.env;
39
+ return typeof block === 'object' && block !== null
40
+ ? block
41
+ : {};
42
+ }
43
+ catch {
44
+ return {};
45
+ }
46
+ }
47
+ export function settingsConflicts(files) {
48
+ const out = [];
49
+ for (const file of files) {
50
+ const block = envBlock(file);
51
+ for (const key of CONFLICTING) {
52
+ const value = block[key];
53
+ if (typeof value === 'string' && value.trim() !== '')
54
+ out.push(`${file}: ${key}`);
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ export function claudeEnv(base, port, token) {
60
+ const kept = Object.entries(base).filter(([key]) => !SCRUBBED.includes(key));
61
+ return {
62
+ ...Object.fromEntries(kept),
63
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${String(port)}`,
64
+ ANTHROPIC_AUTH_TOKEN: token,
65
+ };
66
+ }
67
+ function runClaude(args, env) {
68
+ return new Promise((resolve, reject) => {
69
+ const leaveToChild = () => undefined;
70
+ process.on('SIGINT', leaveToChild);
71
+ process.on('SIGTERM', leaveToChild);
72
+ const child = spawn('claude', args, { stdio: 'inherit', env });
73
+ child.on('error', (err) => {
74
+ reject(new Error(err.code === 'ENOENT'
75
+ ? 'the `claude` command is not on PATH — install Claude Code first'
76
+ : err.message));
77
+ });
78
+ child.on('exit', (code) => {
79
+ resolve(code ?? 1);
80
+ });
81
+ });
82
+ }
83
+ export async function bedrock(argv) {
84
+ const cfg = bedrockConfigFromEnv();
85
+ const conflicts = settingsConflicts(settingsFiles());
86
+ if (conflicts.length > 0)
87
+ throw new Error('metro bedrock: these settings would put Claude Code back on a third-party provider, or override the proxy. Remove them, or point CLAUDE_CONFIG_DIR at a separate profile:\n ' +
88
+ conflicts.join('\n '));
89
+ const token = `mb_${randomBytes(18).toString('base64url')}`;
90
+ const proxy = await startBedrockProxy(cfg, { token });
91
+ const pinned = cfg.model === null ? '' : ` (${cfg.model})`;
92
+ process.stderr.write(`metro bedrock: Claude Code → http://127.0.0.1:${String(proxy.port)} → Bedrock ${cfg.region}${pinned}\n`);
93
+ try {
94
+ return await runClaude(claudeArgs(argv), claudeEnv(process.env, proxy.port, token));
95
+ }
96
+ finally {
97
+ await proxy.close();
98
+ }
99
+ }
package/dist/claude.js ADDED
@@ -0,0 +1,15 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
3
+ export const claudeArgs = (extra) => [
4
+ ...CHANNEL_FLAGS,
5
+ ...extra,
6
+ ];
7
+ export function launchClaude(extra) {
8
+ const leaveToChild = () => undefined;
9
+ process.on('SIGINT', leaveToChild);
10
+ process.on('SIGTERM', leaveToChild);
11
+ const res = spawnSync('claude', claudeArgs(extra), { stdio: 'inherit' });
12
+ if (res.error !== undefined)
13
+ throw new Error('the `claude` command is not on PATH — install Claude Code first');
14
+ return Promise.resolve(res.status ?? 1);
15
+ }
package/dist/cli.js CHANGED
@@ -6,6 +6,8 @@ import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from
6
6
  import { detach, lockedBy, probe, runningPid, stopAll, tail } from './control.js';
7
7
  import { tailEvents } from './tail.js';
8
8
  import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, writeRunToken, } from './runtime.js';
9
+ import { launchClaude } from './claude.js';
10
+ import { bedrock } from './bedrock.js';
9
11
  import { installPlugin, syncPluginServers } from './plugin.js';
10
12
  import { update } from './update.js';
11
13
  import { currentVersion } from './version.js';
@@ -26,6 +28,11 @@ const USAGE = `metro — the command line for your MCP connectors
26
28
  metro whoami print the account and collection this machine may read
27
29
  metro mcp print the mcpServers block for the authorized collection
28
30
  metro plugin set up the Claude Code plugin (connector servers + /metro:login)
31
+ metro claude [args...]
32
+ open Claude Code with the metro channel; every argument is passed through
33
+ metro bedrock [args...]
34
+ the same, with inference on Amazon Bedrock through a local proxy so the
35
+ channel still works (needs AWS_BEARER_TOKEN_BEDROCK and AWS_REGION)
29
36
  metro update update to the newest published version
30
37
  metro version print this CLI's version
31
38
 
@@ -37,6 +44,9 @@ Start Claude Code with all of them, without writing them to disk:
37
44
  METRO_UI_URL where the web UI lives (default https://metro.box)
38
45
  METRO_TOKEN use this connector sign-in instead of the stored one
39
46
  METRO_RUN_TOKEN use this runtime authorization instead of the stored one
47
+ METRO_BEDROCK_MODEL
48
+ send every request to this Bedrock model id (default: derive from the
49
+ model Claude Code asks for, e.g. eu.anthropic.claude-sonnet-4-6)
40
50
  METRO_RUNTIME_DIR run the daemon from this directory instead of the bundled one
41
51
  `;
42
52
  function hostLabel() {
@@ -137,6 +147,8 @@ const COMMANDS = {
137
147
  return 0;
138
148
  },
139
149
  plugin: installPlugin,
150
+ claude: () => launchClaude(process.argv.slice(3)),
151
+ bedrock: () => bedrock(process.argv.slice(3)),
140
152
  update,
141
153
  version: async () => {
142
154
  process.stdout.write(`${currentVersion()}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.27",
3
+ "version": "0.1.0-beta.29",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -91,8 +91,10 @@ export function mcpEndpoint(): string {
91
91
  return `${publicBaseOrDefault()}/mcp`;
92
92
  }
93
93
 
94
+ const LOCAL_MCP_ENDPOINT = 'http://127.0.0.1:8420/mcp';
95
+
94
96
  export function mcpAddCommand(key: string): string {
95
- const url = `${mcpEndpoint()}?token=${key}`;
97
+ const url = `${LOCAL_MCP_ENDPOINT}?token=${key}`;
96
98
  return `claude mcp add --transport http ${SERVER_NAME} "${url}"`;
97
99
  }
98
100
 
@@ -102,23 +104,18 @@ interface KeyPayload {
102
104
  command: string | null;
103
105
  }
104
106
 
105
- const LOCAL_MCP_ENDPOINT = 'http://127.0.0.1:8420/mcp';
106
-
107
- function credentials(key: string, heldLocally: boolean): KeyPayload {
108
- const url = heldLocally
109
- ? `${LOCAL_MCP_ENDPOINT}?token=${key}`
110
- : `${mcpEndpoint()}?token=${key}`;
107
+ function credentials(key: string): KeyPayload {
111
108
  return {
112
109
  key,
113
- endpoint: url,
114
- command: `claude mcp add --transport http ${SERVER_NAME} "${url}"`,
110
+ endpoint: `${LOCAL_MCP_ENDPOINT}?token=${key}`,
111
+ command: mcpAddCommand(key),
115
112
  };
116
113
  }
117
114
 
118
- function keyPayload(agent: AgentSummary, heldLocally: boolean): KeyPayload {
115
+ function keyPayload(agent: AgentSummary): KeyPayload {
119
116
  const value = agent.owned ? agent.key : null;
120
117
  if (value === null) return { key: null, endpoint: null, command: null };
121
- return credentials(value, heldLocally);
118
+ return credentials(value);
122
119
  }
123
120
 
124
121
  function livenessPayload(
@@ -144,7 +141,7 @@ function agentPayload(
144
141
  owned: agent.owned,
145
142
  runtime: agent.owned ? (runtimes.get(agent.id) ?? null) : null,
146
143
  ...livenessPayload(agent, live),
147
- ...keyPayload(agent, agent.owned && runtimes.has(agent.id)),
144
+ ...keyPayload(agent),
148
145
  };
149
146
  }
150
147
 
@@ -224,7 +221,7 @@ async function handleCreate(
224
221
  sendJson(req, res, 201, {
225
222
  id: created.id,
226
223
  name: created.name,
227
- ...credentials(created.key, false),
224
+ ...credentials(created.key),
228
225
  });
229
226
  }
230
227
 
@@ -236,7 +233,6 @@ async function handleResetKey(
236
233
  id: string,
237
234
  ): Promise<void> {
238
235
  const reset = await deps.resetKey(session.email, id);
239
- const held = (await deps.runtimes([id])).has(id);
240
236
  log.info(
241
237
  { agent: reset.name, id: reset.id, owner: session.email },
242
238
  'agent-api: reset agent key',
@@ -245,7 +241,7 @@ async function handleResetKey(
245
241
  id: reset.id,
246
242
  name: reset.name,
247
243
  reset: true,
248
- ...credentials(reset.key, held),
244
+ ...credentials(reset.key),
249
245
  });
250
246
  }
251
247
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.27"
2
+ "version": "0.1.0-beta.29"
3
3
  }