@stage-labs/metro 0.1.0-beta.74 → 0.1.0-beta.76

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.74",
3
+ "version": "0.1.0-beta.76",
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": {
@@ -4,6 +4,15 @@ import { apiFailure, apiSession, cors, readJsonBody, sendJson } from './api-http
4
4
  import { ApiError } from './api-error.js';
5
5
  import { isRecord } from './is-record.js';
6
6
  import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './claude-settings.js';
7
+ import {
8
+ answerClaudeLogin,
9
+ claudeAccount,
10
+ claudeInstalled,
11
+ claudeLoginView,
12
+ endClaudeLogin,
13
+ startClaudeLogin,
14
+ type LoginDeps,
15
+ } from './claude-login.js';
7
16
  import {
8
17
  claudeDir,
9
18
  deleteClaudeSession,
@@ -16,13 +25,14 @@ import {
16
25
 
17
26
  const PREFIX = '/api/claude';
18
27
  const BODY_MAX = SETTINGS_MAX + 4096;
19
- const WRITABLE = new Set(['GET', 'DELETE', 'PUT']);
28
+ const WRITABLE = new Set(['GET', 'DELETE', 'PUT', 'POST']);
20
29
  const PAGE = 100;
21
30
  const PAGE_MAX = 500;
22
31
 
23
32
  export interface ClaudeApiDeps {
24
33
  authorize: (subject: string) => void;
25
34
  dir?: () => string;
35
+ login?: LoginDeps;
26
36
  }
27
37
 
28
38
  function projectOf(query: URLSearchParams): string {
@@ -65,17 +75,38 @@ async function writeAnswer(req: IncomingMessage, path: string, dir: string): Pro
65
75
  return writeClaudeSettings(item, body.text, seenAt, dir);
66
76
  }
67
77
 
78
+ const LOGIN = 'login';
79
+
80
+ async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps): Promise<unknown> {
81
+ const method = req.method ?? 'GET';
82
+ if (id === '') {
83
+ if (method === 'POST') return startClaudeLogin(deps.login);
84
+ if (method === 'GET') return { available: claudeInstalled(), ...claudeAccount() };
85
+ throw new ApiError('method not allowed', 405);
86
+ }
87
+ if (method === 'GET') return claudeLoginView(id);
88
+ if (method === 'DELETE') return endClaudeLogin(id);
89
+ if (method !== 'POST') throw new ApiError('method not allowed', 405);
90
+ const body = await readJsonBody(req);
91
+ if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
92
+ return answerClaudeLogin(id, body.text);
93
+ }
94
+
95
+ function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
96
+ const [head = '', item = ''] = rest;
97
+ if (rest.length !== 2 || head !== 'sessions') throw new ApiError('method not allowed', 405);
98
+ deleteClaudeSession(projectOf(query), item, dir);
99
+ return { deleted: item };
100
+ }
101
+
68
102
  function answer(method: string, path: string, query: URLSearchParams, dir: string): unknown {
69
103
  const rest = parts(path);
70
- const [head = '', item] = rest;
71
- if (method === 'DELETE') {
72
- if (rest.length !== 2 || head !== 'sessions') throw new ApiError('method not allowed', 405);
73
- deleteClaudeSession(projectOf(query), item ?? '', dir);
74
- return { deleted: item };
75
- }
104
+ const [head = '', item = ''] = rest;
105
+ if (method === 'DELETE') return removed(rest, query, dir);
106
+ if (method !== 'GET') throw new ApiError('method not allowed', 405);
76
107
  const handler = rest.length === 1 ? COLLECTIONS[head] : rest.length === 2 ? ITEMS[head] : undefined;
77
108
  if (handler === undefined) throw new ApiError('no such route', 404);
78
- return handler(query, dir, item ?? '');
109
+ return handler(query, dir, item);
79
110
  }
80
111
 
81
112
  export function handleClaudeRequest(
@@ -98,6 +129,8 @@ export function handleClaudeRequest(
98
129
  if (!session) throw new ApiError('unauthorized', 401);
99
130
  deps.authorize(session.subject);
100
131
  const dir = (deps.dir ?? claudeDir)();
132
+ const [head = '', item = ''] = parts(path);
133
+ if (head === LOGIN) return loginAnswer(req, item, deps);
101
134
  if (req.method === 'PUT') return writeAnswer(req, path, dir);
102
135
  return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
103
136
  })
@@ -0,0 +1,167 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { ApiError } from './api-error.js';
4
+ import { isRecord } from './is-record.js';
5
+ import { errMsg, log } from './log.js';
6
+
7
+ const LOGIN_COMMAND = ['claude', 'auth', 'login', '--claudeai'];
8
+ const STATUS_COMMAND = ['auth', 'status', '--json'];
9
+ const TTL_MS = 10 * 60_000;
10
+ const OUTPUT_MAX = 8_000;
11
+ const COLS = 100;
12
+ const ROWS = 40;
13
+ const ESC = String.fromCharCode(27);
14
+ const CSI = /^\[[0-9;?]*[a-zA-Z]/;
15
+ const URL_RE = /https:\/\/[^\s"'`]+/g;
16
+
17
+ export type LoginState = 'pending' | 'done' | 'failed';
18
+
19
+ export interface LoginView {
20
+ id: string;
21
+ state: LoginState;
22
+ url: string | null;
23
+ output: string;
24
+ error: string | null;
25
+ }
26
+
27
+ interface Live {
28
+ id: string;
29
+ startedAt: number;
30
+ state: LoginState;
31
+ output: string;
32
+ error: string | null;
33
+ write: (text: string) => void;
34
+ kill: () => void;
35
+ }
36
+
37
+ export interface LoginDeps {
38
+ command?: string[];
39
+ }
40
+
41
+ let live: Live | null = null;
42
+
43
+ export function plainText(raw: string): string {
44
+ return raw
45
+ .split(ESC)
46
+ .map((part, index) => (index === 0 ? part : part.replace(CSI, '')))
47
+ .join('');
48
+ }
49
+
50
+ export function loginUrlIn(output: string): string | null {
51
+ const found = output.match(URL_RE) ?? [];
52
+ const trimmed = found.map((url) => url.replace(/[.,)]+$/, ''));
53
+ return trimmed.find((url) => url.includes('claude.ai') || url.includes('anthropic.com')) ?? trimmed[0] ?? null;
54
+ }
55
+
56
+ const view = (session: Live): LoginView => ({
57
+ id: session.id,
58
+ state: session.state,
59
+ url: loginUrlIn(session.output),
60
+ output: session.output,
61
+ error: session.error,
62
+ });
63
+
64
+ function sweep(now: number): void {
65
+ if (live !== null && live.state !== 'pending') return;
66
+ if (live !== null && now - live.startedAt > TTL_MS) {
67
+ live.kill();
68
+ live = null;
69
+ }
70
+ }
71
+
72
+ export function claudeInstalled(): boolean {
73
+ const run = spawnSync('claude', ['--version'], { stdio: 'ignore' });
74
+ return run.error === undefined && run.status === 0;
75
+ }
76
+
77
+ export function claudeAccount(): { signedIn: boolean; account: string | null } {
78
+ const run = spawnSync('claude', STATUS_COMMAND, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
79
+ if (run.error !== undefined || typeof run.stdout !== 'string') return { signedIn: false, account: null };
80
+ try {
81
+ const parsed: unknown = JSON.parse(run.stdout);
82
+ if (!isRecord(parsed)) return { signedIn: false, account: null };
83
+ const account = typeof parsed.email === 'string' ? parsed.email : typeof parsed.organization === 'string' ? parsed.organization : null;
84
+ return { signedIn: parsed.authenticated === true || parsed.loggedIn === true || account !== null, account };
85
+ } catch {
86
+ return { signedIn: false, account: null };
87
+ }
88
+ }
89
+
90
+ function note(session: Live, chunk: string): void {
91
+ const next = session.output + plainText(chunk);
92
+ session.output = next.length > OUTPUT_MAX ? next.slice(next.length - OUTPUT_MAX) : next;
93
+ }
94
+
95
+ export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginView {
96
+ sweep(now);
97
+ if (live !== null && live.state === 'pending') return view(live);
98
+ const command = deps.command ?? LOGIN_COMMAND;
99
+ const session: Live = {
100
+ id: randomBytes(9).toString('base64url'),
101
+ startedAt: now,
102
+ state: 'pending',
103
+ output: '',
104
+ error: null,
105
+ write: () => undefined,
106
+ kill: () => undefined,
107
+ };
108
+ const terminal = new Bun.Terminal({
109
+ cols: COLS,
110
+ rows: ROWS,
111
+ data: (_term, chunk: Uint8Array) => {
112
+ note(session, Buffer.from(chunk).toString('utf8'));
113
+ },
114
+ });
115
+ const proc = Bun.spawn(command, { terminal, env: { ...process.env, TERM: 'xterm-256color' } });
116
+ session.write = (text: string) => {
117
+ terminal.write(Buffer.from(`${text}\r`, 'utf8'));
118
+ };
119
+ session.kill = () => {
120
+ proc.kill();
121
+ terminal.close();
122
+ };
123
+ proc.exited
124
+ .then((code) => {
125
+ session.state = code === 0 ? 'done' : 'failed';
126
+ if (code !== 0) session.error = `the login ended with status ${String(code)}`;
127
+ terminal.close();
128
+ log.info({ state: session.state }, 'claude-login: the official login finished');
129
+ })
130
+ .catch((err: unknown) => {
131
+ session.state = 'failed';
132
+ session.error = errMsg(err);
133
+ });
134
+ live = session;
135
+ log.info({ command: command[0] }, 'claude-login: started');
136
+ return view(session);
137
+ }
138
+
139
+ function current(id: string): Live {
140
+ const session = live;
141
+ if (session?.id !== id) throw new ApiError('that sign-in is over; start another one', 404);
142
+ return session;
143
+ }
144
+
145
+ export function claudeLoginView(id: string, now = Date.now()): LoginView {
146
+ sweep(now);
147
+ return view(current(id));
148
+ }
149
+
150
+ export function answerClaudeLogin(id: string, text: string): LoginView {
151
+ const session = current(id);
152
+ if (session.state !== 'pending') throw new ApiError('that sign-in has already finished', 409);
153
+ session.write(text);
154
+ return view(session);
155
+ }
156
+
157
+ export function endClaudeLogin(id: string): { ended: true } {
158
+ const session = current(id);
159
+ session.kill();
160
+ live = null;
161
+ return { ended: true };
162
+ }
163
+
164
+ export const forgetClaudeLogin = (): void => {
165
+ live?.kill();
166
+ live = null;
167
+ };
@@ -113,6 +113,19 @@ export function forwardedHeaders(req: IncomingMessage): Record<string, string> {
113
113
  return out;
114
114
  }
115
115
 
116
+ export function anthropicHeaders(req: IncomingMessage, apiKey: string): Record<string, string> {
117
+ const headers = forwardedHeaders(req);
118
+ delete headers.authorization;
119
+ const betas = (headers['anthropic-beta'] ?? '')
120
+ .split(',')
121
+ .map((beta) => beta.trim())
122
+ .filter((beta) => beta !== '' && !beta.includes('oauth'));
123
+ if (betas.length > 0) headers['anthropic-beta'] = betas.join(',');
124
+ else delete headers['anthropic-beta'];
125
+ headers['x-api-key'] = apiKey;
126
+ return headers;
127
+ }
128
+
116
129
  export interface PipeOptions {
117
130
  keepalive?: boolean;
118
131
  ownCredential?: boolean;
@@ -9,7 +9,7 @@ import {
9
9
  freshAdaptations,
10
10
  type Adaptations,
11
11
  } from './bedrock.js';
12
- import { forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
12
+ import { anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
13
13
  import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, writeModelConfig, type ModelConfig, type Route } from './model-config.js';
14
14
  import { codexCount, codexMessages, freshCodexState, type CodexDeps } from './codex.js';
15
15
  import { OPENROUTER_BASE } from './openrouter.js';
@@ -82,14 +82,15 @@ async function toAnthropic(
82
82
  const payload = explicit ? Buffer.from(JSON.stringify({ ...body, model: route.model })) : raw;
83
83
  const url = `${deps.anthropicBase ?? ANTHROPIC_BASE}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
84
84
  const watch = watchUpstream(res);
85
+ const key = deps.config().anthropic.apiKey;
85
86
  const upstream = await fetch(url, {
86
87
  method: 'POST',
87
- headers: forwardedHeaders(req),
88
+ headers: key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key),
88
89
  body: new Uint8Array(payload),
89
90
  signal: watch.signal,
90
91
  redirect: 'manual',
91
92
  });
92
- await pipeResponse(upstream, res, watch);
93
+ await pipeResponse(upstream, res, watch, key === '' ? {} : { ownCredential: true });
93
94
  }
94
95
 
95
96
  async function toOpenRouter(
@@ -7,6 +7,11 @@ import type { CodexTokens } from './codex-auth.js';
7
7
  export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
8
8
  export type Provider = (typeof PROVIDERS)[number];
9
9
 
10
+ export interface AnthropicSettings {
11
+ apiKey: string;
12
+ model: string;
13
+ }
14
+
10
15
  export interface BedrockSettings {
11
16
  region: string;
12
17
  apiKey: string;
@@ -26,6 +31,7 @@ export interface CodexSettings {
26
31
  export interface ModelConfig {
27
32
  version: 1;
28
33
  provider: Provider;
34
+ anthropic: AnthropicSettings;
29
35
  bedrock: BedrockSettings;
30
36
  openrouter: OpenRouterSettings;
31
37
  codex: CodexSettings;
@@ -41,10 +47,12 @@ export class ModelConfigError extends Error {}
41
47
  export const MODEL_FILE = 'model.json';
42
48
  const MAX_FIELD = 512;
43
49
  const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
50
+ const SMALL_RE = /haiku/i;
44
51
 
45
52
  const empty = (): ModelConfig => ({
46
53
  version: 1,
47
54
  provider: 'anthropic',
55
+ anthropic: { apiKey: '', model: '' },
48
56
  bedrock: { region: '', apiKey: '', model: '' },
49
57
  openrouter: { apiKey: '', model: '' },
50
58
  codex: { model: '', auth: null },
@@ -76,12 +84,14 @@ function tokensFromDisk(raw: unknown): CodexTokens | null {
76
84
  function fromDisk(raw: unknown): ModelConfig {
77
85
  const base = empty();
78
86
  if (!isRecord(raw)) return base;
87
+ const anthropic = isRecord(raw.anthropic) ? raw.anthropic : {};
79
88
  const bedrock = isRecord(raw.bedrock) ? raw.bedrock : {};
80
89
  const openrouter = isRecord(raw.openrouter) ? raw.openrouter : {};
81
90
  const codex = isRecord(raw.codex) ? raw.codex : {};
82
91
  return {
83
92
  version: 1,
84
93
  provider: isProvider(raw.provider) ? raw.provider : 'anthropic',
94
+ anthropic: { apiKey: text(anthropic.apiKey), model: text(anthropic.model) },
85
95
  bedrock: { region: text(bedrock.region), apiKey: text(bedrock.apiKey), model: text(bedrock.model) },
86
96
  openrouter: { apiKey: text(openrouter.apiKey), model: text(openrouter.model) },
87
97
  codex: { model: text(codex.model), auth: tokensFromDisk(codex.auth) },
@@ -108,12 +118,17 @@ export function applyModelUpdate(cfg: ModelConfig, patch: unknown): ModelConfig
108
118
  if (!isRecord(patch)) throw new ModelConfigError('body must be a JSON object');
109
119
  const provider = 'provider' in patch ? patch.provider : cfg.provider;
110
120
  if (!isProvider(provider)) throw new ModelConfigError(`provider must be one of ${PROVIDERS.join(', ')}`);
121
+ const anthropic = isRecord(patch.anthropic) ? patch.anthropic : {};
111
122
  const bedrock = isRecord(patch.bedrock) ? patch.bedrock : {};
112
123
  const openrouter = isRecord(patch.openrouter) ? patch.openrouter : {};
113
124
  const codex = isRecord(patch.codex) ? patch.codex : {};
114
125
  return {
115
126
  version: 1,
116
127
  provider,
128
+ anthropic: {
129
+ apiKey: field(anthropic, 'apiKey', cfg.anthropic.apiKey, 'Anthropic API key'),
130
+ model: field(anthropic, 'model', cfg.anthropic.model, 'Anthropic model'),
131
+ },
117
132
  bedrock: {
118
133
  region: field(bedrock, 'region', cfg.bedrock.region, 'Bedrock region'),
119
134
  apiKey: field(bedrock, 'apiKey', cfg.bedrock.apiKey, 'Bedrock API key'),
@@ -155,17 +170,20 @@ export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
155
170
  provider: cfg.provider,
156
171
  ready: notReady(cfg) === null,
157
172
  reason: notReady(cfg),
173
+ anthropic: { model: cfg.anthropic.model, hasKey: cfg.anthropic.apiKey !== '' },
158
174
  bedrock: { region: cfg.bedrock.region, model: cfg.bedrock.model, hasKey: cfg.bedrock.apiKey !== '' },
159
175
  openrouter: { model: cfg.openrouter.model, hasKey: cfg.openrouter.apiKey !== '' },
160
176
  codex: { model: cfg.codex.model, signedIn: auth !== null, account: auth?.email ?? null, plan: auth?.plan ?? null },
161
177
  };
162
178
  }
163
179
 
180
+ export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
181
+
164
182
  function defaultModelFor(provider: Provider, requested: string, cfg: ModelConfig): string {
165
183
  if (provider === 'openrouter') return requested.includes('/') ? requested : cfg.openrouter.model;
166
184
  if (provider === 'bedrock') return cfg.bedrock.model === '' ? requested : cfg.bedrock.model;
167
185
  if (provider === 'codex') return requested.startsWith('gpt-') ? requested : cfg.codex.model;
168
- return requested;
186
+ return cfg.anthropic.model === '' || isSmallModel(requested) ? requested : cfg.anthropic.model;
169
187
  }
170
188
 
171
189
  export function resolveRoute(requested: string, cfg: ModelConfig): Route {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.74"
2
+ "version": "0.1.0-beta.76"
3
3
  }