@stage-labs/metro 0.1.0-beta.71 → 0.1.0-beta.73

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.71",
3
+ "version": "0.1.0-beta.73",
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": {
@@ -1,7 +1,9 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from './log.js';
3
- import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
3
+ import { apiFailure, apiSession, cors, readJsonBody, sendJson } from './api-http.js';
4
4
  import { ApiError } from './api-error.js';
5
+ import { isRecord } from './is-record.js';
6
+ import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './claude-settings.js';
5
7
  import {
6
8
  claudeDir,
7
9
  deleteClaudeSession,
@@ -13,6 +15,8 @@ import {
13
15
  } from './claude-files.js';
14
16
 
15
17
  const PREFIX = '/api/claude';
18
+ const BODY_MAX = SETTINGS_MAX + 4096;
19
+ const WRITABLE = new Set(['GET', 'DELETE', 'PUT']);
16
20
  const PAGE = 100;
17
21
  const PAGE_MAX = 500;
18
22
 
@@ -39,6 +43,7 @@ const COLLECTIONS: Record<string, Handler> = {
39
43
  projects: (_query, dir) => ({ projects: listClaudeProjects(dir) }),
40
44
  sessions: (query, dir) => ({ sessions: listClaudeSessions(projectOf(query), dir) }),
41
45
  memory: (query, dir) => listMemory(projectOf(query), dir),
46
+ settings: (_query, dir) => ({ files: listClaudeSettings(dir) }),
42
47
  };
43
48
 
44
49
  const ITEMS: Record<string, Handler> = {
@@ -49,8 +54,19 @@ const ITEMS: Record<string, Handler> = {
49
54
  memory: (query, dir, name) => ({ name, content: readMemoryFile(projectOf(query), name, dir) }),
50
55
  };
51
56
 
57
+ const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/').filter(Boolean);
58
+
59
+ async function writeAnswer(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
60
+ const [head = '', item = ''] = parts(path);
61
+ if (head !== 'settings' || item === '') throw new ApiError('method not allowed', 405);
62
+ const body = await readJsonBody(req, BODY_MAX);
63
+ if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
64
+ const seenAt = 'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
65
+ return writeClaudeSettings(item, body.text, seenAt, dir);
66
+ }
67
+
52
68
  function answer(method: string, path: string, query: URLSearchParams, dir: string): unknown {
53
- const rest = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
69
+ const rest = parts(path);
54
70
  const [head = '', item] = rest;
55
71
  if (method === 'DELETE') {
56
72
  if (rest.length !== 2 || head !== 'sessions') throw new ApiError('method not allowed', 405);
@@ -73,7 +89,7 @@ export function handleClaudeRequest(
73
89
  res.writeHead(204, cors(req)).end();
74
90
  return true;
75
91
  }
76
- if (req.method !== 'GET' && req.method !== 'DELETE') {
92
+ if (!WRITABLE.has(req.method ?? '')) {
77
93
  sendJson(req, res, 405, { error: 'method not allowed' });
78
94
  return true;
79
95
  }
@@ -81,7 +97,9 @@ export function handleClaudeRequest(
81
97
  .then((session) => {
82
98
  if (!session) throw new ApiError('unauthorized', 401);
83
99
  deps.authorize(session.subject);
84
- return answer(req.method ?? 'GET', path, new URLSearchParams(search), (deps.dir ?? claudeDir)());
100
+ const dir = (deps.dir ?? claudeDir)();
101
+ if (req.method === 'PUT') return writeAnswer(req, path, dir);
102
+ return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
85
103
  })
86
104
  .then((body) => {
87
105
  sendJson(req, res, 200, body);
@@ -0,0 +1,117 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { ApiError } from './api-error.js';
4
+ import { claudeDir, listClaudeProjects } from './claude-files.js';
5
+ import { isRecord } from './is-record.js';
6
+ import { errMsg } from './log.js';
7
+
8
+ export const SETTINGS_MAX = 256 * 1024;
9
+ const USER_ID = 'user';
10
+ const LOCAL_SUFFIX = '.local';
11
+ const DEFAULT_MODE = 0o644;
12
+
13
+ export type SettingsScope = 'user' | 'project' | 'local';
14
+
15
+ export interface ClaudeSettingsFile {
16
+ id: string;
17
+ scope: SettingsScope;
18
+ label: string;
19
+ path: string;
20
+ exists: boolean;
21
+ editable: boolean;
22
+ text: string;
23
+ modifiedAt: string | null;
24
+ }
25
+
26
+ const missing = (id: string, scope: SettingsScope, label: string, path: string): ClaudeSettingsFile => ({
27
+ id,
28
+ scope,
29
+ label,
30
+ path,
31
+ exists: false,
32
+ editable: true,
33
+ text: '',
34
+ modifiedAt: null,
35
+ });
36
+
37
+ function entryOf(id: string, scope: SettingsScope, label: string, path: string): ClaudeSettingsFile {
38
+ if (!existsSync(path)) return missing(id, scope, label, path);
39
+ const stat = statSync(path);
40
+ const editable = stat.size <= SETTINGS_MAX;
41
+ return {
42
+ id,
43
+ scope,
44
+ label,
45
+ path,
46
+ exists: true,
47
+ editable,
48
+ text: editable ? readFileSync(path, 'utf8') : '',
49
+ modifiedAt: stat.mtime.toISOString(),
50
+ };
51
+ }
52
+
53
+ const PROJECT_FILES: [string, SettingsScope, string][] = [
54
+ ['', 'project', 'settings.json'],
55
+ [LOCAL_SUFFIX, 'local', 'settings.local.json'],
56
+ ];
57
+
58
+ function projectEntries(dir: string): ClaudeSettingsFile[] {
59
+ const out: ClaudeSettingsFile[] = [];
60
+ for (const project of listClaudeProjects(dir)) {
61
+ const cwd = project.cwd;
62
+ if (cwd === null) continue;
63
+ for (const [suffix, scope, name] of PROJECT_FILES) {
64
+ const path = join(cwd, '.claude', name);
65
+ if (existsSync(path)) out.push(entryOf(`${project.id}${suffix}`, scope, cwd, path));
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ export function listClaudeSettings(dir = claudeDir()): ClaudeSettingsFile[] {
72
+ return [entryOf(USER_ID, 'user', 'This machine', join(dir, 'settings.json')), ...projectEntries(dir)];
73
+ }
74
+
75
+ function assertSettingsJson(text: string): void {
76
+ if (text.length > SETTINGS_MAX) throw new ApiError('that is more text than a settings file may hold', 413);
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = JSON.parse(text);
80
+ } catch (err) {
81
+ throw new ApiError(`that is not valid JSON: ${errMsg(err)}`, 400);
82
+ }
83
+ if (!isRecord(parsed)) throw new ApiError('Claude Code settings must be a JSON object', 400);
84
+ }
85
+
86
+ function modeOf(path: string): number {
87
+ try {
88
+ return statSync(path).mode & 0o777;
89
+ } catch {
90
+ return DEFAULT_MODE;
91
+ }
92
+ }
93
+
94
+ function writeAtomic(path: string, text: string): void {
95
+ const mode = existsSync(path) ? modeOf(path) : DEFAULT_MODE;
96
+ mkdirSync(dirname(path), { recursive: true });
97
+ const tmp = `${path}.metro-${String(process.pid)}`;
98
+ writeFileSync(tmp, text, { mode });
99
+ chmodSync(tmp, mode);
100
+ renameSync(tmp, path);
101
+ }
102
+
103
+ export function writeClaudeSettings(
104
+ id: string,
105
+ text: string,
106
+ seenAt: string | null | undefined,
107
+ dir = claudeDir(),
108
+ ): ClaudeSettingsFile {
109
+ const target = listClaudeSettings(dir).find((file) => file.id === id);
110
+ if (target === undefined) throw new ApiError('no such settings file', 404);
111
+ if (!target.editable) throw new ApiError('that settings file is too large to edit here', 409);
112
+ assertSettingsJson(text);
113
+ if (seenAt !== undefined && seenAt !== target.modifiedAt)
114
+ throw new ApiError('that file changed on disk since you opened it; reload it before saving', 409);
115
+ writeAtomic(target.path, text);
116
+ return entryOf(target.id, target.scope, target.label, target.path);
117
+ }
@@ -6,6 +6,7 @@ import { log } from './log.js';
6
6
  import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from '../gateway/codex-auth.js';
7
7
  import { beginDeviceLogin, pollDeviceLogin } from '../gateway/codex-device.js';
8
8
  import { codexModels, currentTokens, freshCodexState } from '../gateway/codex.js';
9
+ import { openrouterModels } from '../gateway/openrouter.js';
9
10
  import type { CodexTokens } from '../gateway/codex-auth.js';
10
11
  import { GatewayError } from '../gateway/forward.js';
11
12
  import {
@@ -20,6 +21,7 @@ import {
20
21
 
21
22
  const PATH = '/api/model';
22
23
  const CODEX = '/api/model/codex/';
24
+ const OPENROUTER = '/api/model/openrouter/';
23
25
  const BODY_MAX = 16 * 1024;
24
26
  const DEVICE_PREFIX = 'device/';
25
27
  const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
@@ -32,6 +34,7 @@ export interface ModelApiDeps {
32
34
  fetchImpl?: typeof fetch;
33
35
  codexHome?: string;
34
36
  codexBase?: string;
37
+ openrouterBase?: string;
35
38
  }
36
39
 
37
40
  interface Store {
@@ -129,23 +132,43 @@ const CODEX_ROUTES: Record<string, Route> = {
129
132
  },
130
133
  };
131
134
 
132
- function routeFor(path: string, method: string | undefined): Route | number {
133
- if (path === PATH) {
134
- if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(publicModelConfig(store.read())) };
135
- if (method === 'PUT') return { method: 'POST', run: (req, _deps, store) => update(req, store) };
136
- return 405;
137
- }
138
- const rest = path.slice(CODEX.length);
139
- const route = CODEX_ROUTES[rest];
140
- if (route !== undefined) return route.method === method ? route : 405;
135
+ const OPENROUTER_ROUTES: Record<string, Route> = {
136
+ models: {
137
+ method: 'GET',
138
+ run: async (_req, deps) => ({ models: await openrouterModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }),
139
+ },
140
+ };
141
+
142
+ const named = (table: Record<string, Route>, name: string, method: string | undefined): Route | number => {
143
+ const route = table[name];
144
+ if (route === undefined) return 404;
145
+ return route.method === method ? route : 405;
146
+ };
147
+
148
+ function settingsRoute(method: string | undefined): Route | number {
149
+ if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(publicModelConfig(store.read())) };
150
+ if (method === 'PUT') return { method: 'POST', run: (req, _deps, store) => update(req, store) };
151
+ return 405;
152
+ }
153
+
154
+ function codexRoute(rest: string, method: string | undefined): Route | number {
155
+ if (rest in CODEX_ROUTES) return named(CODEX_ROUTES, rest, method);
141
156
  const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
142
157
  if (!DEVICE_ID_RE.test(id)) return 404;
143
158
  return method === 'GET' ? { method: 'GET', run: (_req, deps, store) => pollDevice(id, deps, store) } : 405;
144
159
  }
145
160
 
161
+ const mine = (path: string): boolean => path === PATH || path.startsWith(CODEX) || path.startsWith(OPENROUTER);
162
+
163
+ function routeFor(path: string, method: string | undefined): Route | number {
164
+ if (path === PATH) return settingsRoute(method);
165
+ if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
166
+ return codexRoute(path.slice(CODEX.length), method);
167
+ }
168
+
146
169
  export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
147
170
  const path = (req.url ?? '').split('?')[0] ?? '';
148
- if (path !== PATH && !path.startsWith(CODEX)) return false;
171
+ if (!mine(path)) return false;
149
172
  if (req.method === 'OPTIONS') {
150
173
  res.writeHead(204, cors(req)).end();
151
174
  return true;
@@ -34,12 +34,22 @@ function sizeTmuxWindow(command: string[], session: string, cols: number, rows:
34
34
  if (command[0] !== 'tmux') return;
35
35
  const child = spawn('tmux', resizeWindowArgs(session, cols, rows), { stdio: 'ignore' });
36
36
  child.on('error', (err) => {
37
- log.debug({ err: errMsg(err) }, 'terminal: resize-window failed');
37
+ log.warn({ err: errMsg(err) }, 'terminal: resize-window could not run');
38
+ });
39
+ child.on('exit', (code) => {
40
+ if (code !== 0) log.warn({ code, session, cols, rows }, 'terminal: tmux refused the window resize');
38
41
  });
39
42
  }
40
43
 
41
- const dimension = (raw: unknown, fallback: number): number =>
42
- typeof raw === 'number' && Number.isInteger(raw) && raw > 1 && raw <= MAX_DIMENSION ? raw : fallback;
44
+ const dimension = (raw: unknown): number | null =>
45
+ typeof raw === 'number' && Number.isInteger(raw) && raw > 1 && raw <= MAX_DIMENSION ? raw : null;
46
+
47
+ export function sizeFrom(control: unknown): { cols: number; rows: number } | null {
48
+ if (!isRecord(control)) return null;
49
+ const cols = dimension(control.cols);
50
+ const rows = dimension(control.rows);
51
+ return cols === null || rows === null ? null : { cols, rows };
52
+ }
43
53
 
44
54
  function runTerminal(ws: WebSocket, command: string[], subject: string, session: string): void {
45
55
  const terminal = new Bun.Terminal({
@@ -61,12 +71,11 @@ function runTerminal(ws: WebSocket, command: string[], subject: string, session:
61
71
  return;
62
72
  }
63
73
  try {
64
- const control: unknown = JSON.parse(bytes.toString('utf8'));
65
- if (isRecord(control) && 'cols' in control) {
66
- const cols = dimension(control.cols, DEFAULT_COLS);
67
- const rows = dimension(control.rows, DEFAULT_ROWS);
68
- terminal.resize(cols, rows);
69
- sizeTmuxWindow(command, session, cols, rows);
74
+ const size = sizeFrom(JSON.parse(bytes.toString('utf8')));
75
+ if (size !== null) {
76
+ terminal.resize(size.cols, size.rows);
77
+ proc.kill('SIGWINCH');
78
+ sizeTmuxWindow(command, session, size.cols, size.rows);
70
79
  }
71
80
  } catch (err) {
72
81
  log.warn({ err: errMsg(err) }, 'terminal: bad control frame');
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import { arch, platform, release } from 'node:os';
4
4
  import { isRecord } from '../daemon/is-record.js';
5
- import { errMsg } from '../daemon/log.js';
5
+ import { errMsg, log } from '../daemon/log.js';
6
6
  import { refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
7
7
  import { assembleMessage, CodexEventTranslator, parseEvent, SseParser } from './codex-stream.js';
8
8
  import { ToolNames, toResponsesRequest } from './codex-translate.js';
@@ -10,7 +10,8 @@ import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage,
10
10
  import type { ModelConfig } from './model-config.js';
11
11
 
12
12
  export const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
13
- const CODEX_VERSION = '0.104.0';
13
+ const CODEX_VERSION = '0.153.4';
14
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/;
14
15
  const PING_MS = 25_000;
15
16
  const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
16
17
 
@@ -30,7 +31,15 @@ export const freshCodexState = (): CodexState => ({ refreshing: null, latest: nu
30
31
 
31
32
  const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
32
33
 
33
- export const userAgent = (): string => `codex_cli_rs/${CODEX_VERSION} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
34
+ export function codexVersion(): string {
35
+ const wanted = process.env.METRO_CODEX_VERSION?.trim() ?? '';
36
+ if (wanted === '') return CODEX_VERSION;
37
+ if (VERSION_RE.test(wanted)) return wanted;
38
+ log.warn({ value: wanted }, 'gateway: METRO_CODEX_VERSION is not a version like 0.153.4; using the built-in one');
39
+ return CODEX_VERSION;
40
+ }
41
+
42
+ export const userAgent = (): string => `codex_cli_rs/${codexVersion()} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
34
43
 
35
44
  function headersFor(tokens: CodexTokens, sessionId: string): Record<string, string> {
36
45
  return {
@@ -192,7 +201,7 @@ export function codexCount(res: ServerResponse, body: Record<string, unknown>):
192
201
  }
193
202
 
194
203
  export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
195
- const res = await (deps.fetchImpl ?? fetch)(`${deps.base ?? CODEX_BASE}/models?client_version=${CODEX_VERSION}`, {
204
+ const res = await (deps.fetchImpl ?? fetch)(`${deps.base ?? CODEX_BASE}/models?client_version=${codexVersion()}`, {
196
205
  headers: { ...headersFor(tokens, randomUUID()), accept: 'application/json' },
197
206
  redirect: 'manual',
198
207
  });
@@ -12,11 +12,11 @@ import {
12
12
  import { 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
+ import { OPENROUTER_BASE } from './openrouter.js';
15
16
  import type { CodexTokens } from './codex-auth.js';
16
17
 
17
18
  export const GATEWAY_PREFIX = '/gateway';
18
19
  export const ANTHROPIC_BASE = 'https://api.anthropic.com';
19
- export const OPENROUTER_BASE = 'https://openrouter.ai/api';
20
20
  const MESSAGES = '/v1/messages';
21
21
  const COUNT = '/v1/messages/count_tokens';
22
22
  const MODELS = '/v1/models';
@@ -0,0 +1,31 @@
1
+ import { GatewayError } from './forward.js';
2
+
3
+ export const OPENROUTER_BASE = 'https://openrouter.ai/api';
4
+ const MODELS_MAX = 2000;
5
+
6
+ export interface OpenRouterModel {
7
+ id: string;
8
+ name: string;
9
+ }
10
+
11
+ const str = (value: unknown): string => (typeof value === 'string' ? value : '');
12
+
13
+ function modelOf(entry: unknown): OpenRouterModel | null {
14
+ if (typeof entry !== 'object' || entry === null) return null;
15
+ const row = entry as { id?: unknown; name?: unknown };
16
+ const id = str(row.id);
17
+ return id === '' ? null : { id, name: str(row.name) || id };
18
+ }
19
+
20
+ export async function openrouterModels(base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<OpenRouterModel[]> {
21
+ const res = await fetchImpl(`${base}/v1/models`, { headers: { accept: 'application/json' }, redirect: 'manual' });
22
+ if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not list its models (${String(res.status)})`);
23
+ const body: unknown = await res.json();
24
+ const data = typeof body === 'object' && body !== null ? (body as { data?: unknown }).data : undefined;
25
+ if (!Array.isArray(data)) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no model list');
26
+ return data
27
+ .map(modelOf)
28
+ .filter((model): model is OpenRouterModel => model !== null)
29
+ .slice(0, MODELS_MAX)
30
+ .sort((a, b) => a.id.localeCompare(b.id));
31
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.71"
2
+ "version": "0.1.0-beta.73"
3
3
  }