@stage-labs/metro 0.1.0-beta.123 → 0.1.0-beta.125

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.123",
3
+ "version": "0.1.0-beta.125",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.123",
3
+ "version": "0.1.0-beta.125",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -31,6 +31,8 @@ import {
31
31
  stopSession,
32
32
  type SessionDeps,
33
33
  } from './session.js';
34
+ import { claudeVersion, updateClaude, type VersionDeps } from './version.js';
35
+ import { readSessionFile, SESSION_FILE_MAX, writeSessionFile } from './session-files.js';
34
36
  import {
35
37
  claudeDir,
36
38
  deleteClaudeSession,
@@ -44,6 +46,7 @@ import {
44
46
 
45
47
  const PREFIX = '/api/claude';
46
48
  const BODY_MAX = SETTINGS_MAX + 4096;
49
+ const SESSION_BODY_MAX = SESSION_FILE_MAX + 4096;
47
50
  const WRITABLE = new Set(['GET', 'DELETE', 'PUT', 'POST']);
48
51
  const PAGE = 100;
49
52
  const PAGE_MAX = 500;
@@ -54,6 +57,7 @@ export interface ClaudeApiDeps {
54
57
  login?: LoginDeps;
55
58
  session?: SessionDeps;
56
59
  setup?: SetupDeps;
60
+ version?: VersionDeps;
57
61
  }
58
62
 
59
63
  function projectOf(query: URLSearchParams): string {
@@ -83,6 +87,7 @@ const COLLECTIONS: Record<string, Handler> = {
83
87
 
84
88
  const ITEMS: Record<string, Handler> = {
85
89
  sessions: (query, dir, id) => {
90
+ if (query.get('raw') === '1') return { id, text: readSessionFile(projectOf(query), id, dir) };
86
91
  const { offset, limit } = pageOf(query);
87
92
  return readTranscript(projectOf(query), id, offset, limit, dir);
88
93
  },
@@ -95,17 +100,21 @@ const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/
95
100
  const seenIn = (body: Record<string, unknown>): string | null | undefined =>
96
101
  'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
97
102
 
98
- const WRITE_HEADS = new Set(['settings', 'skills', 'memory']);
103
+ const WRITE_HEADS = new Set(['settings', 'skills', 'memory', 'sessions']);
104
+
105
+ function writeItem(head: string, item: string, text: string, seen: string | null | undefined, search: string, dir: string): unknown {
106
+ if (head === 'skills') return writeClaudeSkill(decodeURIComponent(item), text, seen, dir);
107
+ if (head === 'sessions') return writeSessionFile(projectOf(new URLSearchParams(search)), item, text, dir);
108
+ if (head === 'memory') return writeMemoryFile(projectOf(new URLSearchParams(search)), decodeURIComponent(item), text, dir);
109
+ return writeClaudeSettings(item, text, seen, dir);
110
+ }
99
111
 
100
112
  async function writeAnswer(req: IncomingMessage, path: string, search: string, dir: string): Promise<unknown> {
101
113
  const [head = '', item = ''] = parts(path);
102
114
  if (!WRITE_HEADS.has(head) || item === '') throw new ApiError('method not allowed', 405);
103
- const body = await readJsonBody(req, BODY_MAX);
115
+ const body = await readJsonBody(req, head === 'sessions' ? SESSION_BODY_MAX : BODY_MAX);
104
116
  if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
105
- if (head === 'skills') return writeClaudeSkill(decodeURIComponent(item), body.text, seenIn(body), dir);
106
- if (head === 'memory')
107
- return writeMemoryFile(projectOf(new URLSearchParams(search)), decodeURIComponent(item), body.text, dir);
108
- return writeClaudeSettings(item, body.text, seenIn(body), dir);
117
+ return writeItem(head, item, body.text, seenIn(body), search, dir);
109
118
  }
110
119
 
111
120
  async function created(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
@@ -120,6 +129,7 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
120
129
  const LOGIN = 'login';
121
130
  const SESSION = 'session';
122
131
  const SETUP = 'setup';
132
+ const VERSION = 'version';
123
133
 
124
134
  interface SetupChange {
125
135
  privacy?: boolean;
@@ -211,9 +221,18 @@ function answer(method: string, path: string, query: URLSearchParams, dir: strin
211
221
  return handler(query, dir, item);
212
222
  }
213
223
 
224
+ function versionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
225
+ const version: VersionDeps = { ...deps.version, session: deps.version?.session ?? deps.session };
226
+ const method = req.method ?? 'GET';
227
+ if (method === 'GET') return claudeVersion(version);
228
+ if (method !== 'POST') throw new ApiError('method not allowed', 405);
229
+ return updateClaude(version);
230
+ }
231
+
214
232
  const SINGLETONS: Record<string, (req: IncomingMessage, deps: ClaudeApiDeps) => Promise<unknown>> = {
215
233
  [SESSION]: sessionAnswer,
216
234
  [SETUP]: setupAnswer,
235
+ [VERSION]: versionAnswer,
217
236
  };
218
237
 
219
238
  function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
@@ -17,8 +17,8 @@ import { createInterface } from 'node:readline';
17
17
  import { ApiError } from '@metro-labs/http/api-error';
18
18
  import { isRecord } from '@metro-labs/core/is-record';
19
19
 
20
- const PROJECT_RE = /^[A-Za-z0-9._-]{1,200}$/;
21
- const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
20
+ export const PROJECT_RE = /^[A-Za-z0-9._-]{1,200}$/;
21
+ export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
22
22
  const MEMORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}\.md$/;
23
23
  const EDGE_BYTES = 64 * 1024;
24
24
  const TEXT_CAP = 20_000;
@@ -39,7 +39,7 @@ export function claudeDir(): string {
39
39
 
40
40
  const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
41
41
 
42
- function safeName(value: string, re: RegExp, what: string): string {
42
+ export function safeName(value: string, re: RegExp, what: string): string {
43
43
  if (!re.test(value) || value === '.' || value === '..')
44
44
  throw new ApiError(`that is not a ${what}`, 400);
45
45
  return value;
@@ -0,0 +1,44 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ApiError } from '@metro-labs/http/api-error';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { claudeDir, PROJECT_RE, safeName, SESSION_RE } from './files.js';
6
+
7
+ export const SESSION_FILE_MAX = 64 * 1024 * 1024;
8
+ const FILE_MODE = 0o644;
9
+
10
+ export interface SessionFile {
11
+ id: string;
12
+ bytes: number;
13
+ }
14
+
15
+ const transcriptPath = (project: string, session: string, dir: string): string =>
16
+ join(dir, 'projects', safeName(project, PROJECT_RE, 'Claude project id'), `${safeName(session, SESSION_RE, 'session id')}.jsonl`);
17
+
18
+ export function readSessionFile(project: string, session: string, dir = claudeDir()): string {
19
+ const path = transcriptPath(project, session, dir);
20
+ if (!existsSync(path)) throw new ApiError('no such session', 404);
21
+ if (statSync(path).size > SESSION_FILE_MAX) throw new ApiError(`a session file is at most ${String(SESSION_FILE_MAX)} bytes`, 400);
22
+ return readFileSync(path, 'utf8');
23
+ }
24
+
25
+ function firstLineIsJson(text: string): boolean {
26
+ const first = text.split('\n').find((line) => line.trim() !== '');
27
+ if (first === undefined) return false;
28
+ try {
29
+ return isRecord(JSON.parse(first));
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ export function writeSessionFile(project: string, session: string, text: string, dir = claudeDir()): SessionFile {
36
+ if (!firstLineIsJson(text)) throw new ApiError('a session file is JSON lines, one Claude Code entry per line', 400);
37
+ if (Buffer.byteLength(text, 'utf8') > SESSION_FILE_MAX) throw new ApiError(`a session file is at most ${String(SESSION_FILE_MAX)} bytes`, 400);
38
+ const path = transcriptPath(project, session, dir);
39
+ mkdirSync(join(path, '..'), { recursive: true });
40
+ const tmp = `${path}.metro-${String(process.pid)}`;
41
+ writeFileSync(tmp, text, { mode: FILE_MODE });
42
+ renameSync(tmp, path);
43
+ return { id: safeName(session, SESSION_RE, 'session id'), bytes: statSync(path).size };
44
+ }
@@ -0,0 +1,109 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { ApiError } from '@metro-labs/http/api-error';
5
+ import { errMsg, log } from '@metro-labs/core/log';
6
+ import { isRecord } from '@metro-labs/core/is-record';
7
+ import { sessionRunning, stopSession, type SessionDeps } from './session.js';
8
+
9
+ const LATEST_URL = 'https://registry.npmjs.org/@anthropic-ai/claude-code/latest';
10
+ const VERSION_RE = /(\d+)\.(\d+)\.(\d+)/;
11
+ const CACHE_MS = 10 * 60_000;
12
+ const INSTALL_TIMEOUT_MS = 5 * 60_000;
13
+ const TAIL = 400;
14
+
15
+ export interface VersionDeps {
16
+ claude?: string;
17
+ fetchImpl?: typeof fetch;
18
+ session?: SessionDeps;
19
+ now?: () => number;
20
+ }
21
+
22
+ export interface ClaudeVersion {
23
+ installed: string | null;
24
+ latest: string | null;
25
+ newer: boolean;
26
+ }
27
+
28
+ let cached: { latest: string | null; at: number } | null = null;
29
+
30
+ const candidates = (deps: VersionDeps): string[] => (deps.claude === undefined ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [deps.claude]);
31
+
32
+ const parse = (text: string): string | null => {
33
+ const m = VERSION_RE.exec(text);
34
+ return m === null ? null : `${m[1] ?? ''}.${m[2] ?? ''}.${m[3] ?? ''}`;
35
+ };
36
+
37
+ function probe(bin: string): string | null {
38
+ const run = spawnSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
39
+ return run.error === undefined && run.status === 0 && typeof run.stdout === 'string' ? parse(run.stdout) : null;
40
+ }
41
+
42
+ function located(deps: VersionDeps): { bin: string; installed: string } | null {
43
+ for (const bin of candidates(deps)) {
44
+ const installed = probe(bin);
45
+ if (installed !== null) return { bin, installed };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ export function newerThan(a: string, b: string): boolean {
51
+ const left = a.split('.').map(Number);
52
+ const right = b.split('.').map(Number);
53
+ for (let i = 0; i < 3; i += 1) {
54
+ const d = (left[i] ?? 0) - (right[i] ?? 0);
55
+ if (d !== 0) return d > 0;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ export async function latestClaudeVersion(deps: VersionDeps = {}): Promise<string | null> {
61
+ const now = (deps.now ?? Date.now)();
62
+ if (cached !== null && now - cached.at < CACHE_MS) return cached.latest;
63
+ const fetchImpl = deps.fetchImpl ?? fetch;
64
+ let latest: string | null = null;
65
+ try {
66
+ const res = await fetchImpl(LATEST_URL, { headers: { accept: 'application/json' }, redirect: 'manual' });
67
+ const body: unknown = res.ok ? await res.json() : null;
68
+ latest = isRecord(body) && typeof body.version === 'string' ? parse(body.version) : null;
69
+ } catch (err) {
70
+ log.warn({ err: errMsg(err) }, 'claude-version: could not read the latest version from npm');
71
+ }
72
+ cached = { latest, at: now };
73
+ return latest;
74
+ }
75
+
76
+ export function forgetClaudeVersion(): void {
77
+ cached = null;
78
+ }
79
+
80
+ export async function claudeVersion(deps: VersionDeps = {}): Promise<ClaudeVersion> {
81
+ const installed = located(deps)?.installed ?? null;
82
+ const latest = await latestClaudeVersion(deps);
83
+ return { installed, latest, newer: installed !== null && latest !== null && newerThan(latest, installed) };
84
+ }
85
+
86
+ function install(bin: string): void {
87
+ const run = spawnSync(bin, ['install', 'latest'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: INSTALL_TIMEOUT_MS });
88
+ if (run.error !== undefined) throw new ApiError(`claude install latest failed: ${errMsg(run.error)}`, 502);
89
+ if (run.status === 0) return;
90
+ const said = `${run.stderr ?? ''}${run.stdout ?? ''}`.trim().slice(-TAIL);
91
+ throw new ApiError(`claude install latest failed: ${said}`, 502);
92
+ }
93
+
94
+ function restartIfChanged(changed: boolean, session: SessionDeps): boolean {
95
+ if (!changed || !sessionRunning(session.tmux ?? 'tmux')) return false;
96
+ stopSession(session);
97
+ return true;
98
+ }
99
+
100
+ export async function updateClaude(deps: VersionDeps = {}): Promise<ClaudeVersion & { restarted: boolean }> {
101
+ const found = located(deps);
102
+ if (found === null) throw new ApiError('Claude Code is not installed on this machine', 404);
103
+ install(found.bin);
104
+ forgetClaudeVersion();
105
+ const after = await claudeVersion(deps);
106
+ const restarted = restartIfChanged(after.installed !== null && after.installed !== found.installed, deps.session ?? {});
107
+ log.info({ from: found.installed, to: after.installed, restarted }, 'claude-version: Claude Code updated');
108
+ return { ...after, restarted };
109
+ }
@@ -16,6 +16,7 @@ import { GatewayError } from './forward.js';
16
16
  import {
17
17
  applyModelUpdate,
18
18
  ModelConfigError,
19
+ parseModelConfig,
19
20
  publicModelConfig,
20
21
  readModelConfig,
21
22
  setCodexAuth,
@@ -28,6 +29,9 @@ const CODEX = '/api/model/codex/';
28
29
  const OPENROUTER = '/api/model/openrouter/';
29
30
  const ANTHROPIC = '/api/model/anthropic/';
30
31
  const BEDROCK = '/api/model/bedrock/';
32
+ const BUNDLE = '/api/model/bundle';
33
+ const RESTORE = '/api/model/restore';
34
+ const RESTORE_MAX = 64 * 1024;
31
35
  const BODY_MAX = 16 * 1024;
32
36
  const DEVICE_PREFIX = 'device/';
33
37
  const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
@@ -199,6 +203,21 @@ const named = (table: Record<string, Route>, name: string, method: string | unde
199
203
  return route.method === method ? route : 405;
200
204
  };
201
205
 
206
+ async function restore(req: IncomingMessage, store: Store, deps: ModelApiDeps): Promise<unknown> {
207
+ const body = await readJsonBody(req, RESTORE_MAX);
208
+ if (!isRecord(body)) throw new ApiError('body must be a JSON object', 400);
209
+ const next = parseModelConfig(body);
210
+ store.write(next);
211
+ syncAvailableModelsQuietly(deps.setup ?? {}, next);
212
+ log.info({ provider: next.provider }, 'model-api: model setup restored from a file');
213
+ return settingsBody(next);
214
+ }
215
+
216
+ function bundleRoute(path: string, method: string | undefined): Route | number {
217
+ if (path === BUNDLE) return method === 'GET' ? { method: 'GET', run: (_req, _deps, store) => Promise.resolve(store.read()) } : 405;
218
+ return method === 'POST' ? { method: 'POST', run: (req, deps, store) => restore(req, store, deps) } : 405;
219
+ }
220
+
202
221
  function settingsRoute(method: string | undefined): Route | number {
203
222
  if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(store.read(), deps) };
204
223
  if (method === 'PUT') return { method: 'POST', run: (req, deps, store) => update(req, store, deps) };
@@ -213,10 +232,11 @@ function codexRoute(rest: string, method: string | undefined): Route | number {
213
232
  }
214
233
 
215
234
  const mine = (path: string): boolean =>
216
- path === PATH || path.startsWith(CODEX) || path.startsWith(OPENROUTER) || path.startsWith(ANTHROPIC) || path.startsWith(BEDROCK);
235
+ path === PATH || path === BUNDLE || path === RESTORE || path.startsWith(CODEX) || path.startsWith(OPENROUTER) || path.startsWith(ANTHROPIC) || path.startsWith(BEDROCK);
217
236
 
218
237
  function routeFor(path: string, method: string | undefined): Route | number {
219
238
  if (path === PATH) return settingsRoute(method);
239
+ if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
220
240
  if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
221
241
  if (path.startsWith(ANTHROPIC)) return named(ANTHROPIC_ROUTES, path.slice(ANTHROPIC.length), method);
222
242
  if (path.startsWith(BEDROCK)) return named(BEDROCK_ROUTES, path.slice(BEDROCK.length), method);
@@ -82,7 +82,7 @@ function tokensFromDisk(raw: unknown): CodexTokens | null {
82
82
  };
83
83
  }
84
84
 
85
- function fromDisk(raw: unknown): ModelConfig {
85
+ export function parseModelConfig(raw: unknown): ModelConfig {
86
86
  const base = empty();
87
87
  if (!isRecord(raw)) return base;
88
88
  const anthropic = isRecord(raw.anthropic) ? raw.anthropic : {};
@@ -100,7 +100,7 @@ function fromDisk(raw: unknown): ModelConfig {
100
100
  }
101
101
 
102
102
  export function readModelConfig(dir = agentsDir()): ModelConfig {
103
- return fromDisk(readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' }));
103
+ return parseModelConfig(readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' }));
104
104
  }
105
105
 
106
106
  export function writeModelConfig(cfg: ModelConfig, dir = agentsDir()): void {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.123"
2
+ "version": "0.1.0-beta.125"
3
3
  }