@stage-labs/metro 0.1.0-beta.124 → 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.124",
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.124",
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",
@@ -32,6 +32,7 @@ import {
32
32
  type SessionDeps,
33
33
  } from './session.js';
34
34
  import { claudeVersion, updateClaude, type VersionDeps } from './version.js';
35
+ import { readSessionFile, SESSION_FILE_MAX, writeSessionFile } from './session-files.js';
35
36
  import {
36
37
  claudeDir,
37
38
  deleteClaudeSession,
@@ -45,6 +46,7 @@ import {
45
46
 
46
47
  const PREFIX = '/api/claude';
47
48
  const BODY_MAX = SETTINGS_MAX + 4096;
49
+ const SESSION_BODY_MAX = SESSION_FILE_MAX + 4096;
48
50
  const WRITABLE = new Set(['GET', 'DELETE', 'PUT', 'POST']);
49
51
  const PAGE = 100;
50
52
  const PAGE_MAX = 500;
@@ -85,6 +87,7 @@ const COLLECTIONS: Record<string, Handler> = {
85
87
 
86
88
  const ITEMS: Record<string, Handler> = {
87
89
  sessions: (query, dir, id) => {
90
+ if (query.get('raw') === '1') return { id, text: readSessionFile(projectOf(query), id, dir) };
88
91
  const { offset, limit } = pageOf(query);
89
92
  return readTranscript(projectOf(query), id, offset, limit, dir);
90
93
  },
@@ -97,17 +100,21 @@ const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/
97
100
  const seenIn = (body: Record<string, unknown>): string | null | undefined =>
98
101
  'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
99
102
 
100
- 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
+ }
101
111
 
102
112
  async function writeAnswer(req: IncomingMessage, path: string, search: string, dir: string): Promise<unknown> {
103
113
  const [head = '', item = ''] = parts(path);
104
114
  if (!WRITE_HEADS.has(head) || item === '') throw new ApiError('method not allowed', 405);
105
- const body = await readJsonBody(req, BODY_MAX);
115
+ const body = await readJsonBody(req, head === 'sessions' ? SESSION_BODY_MAX : BODY_MAX);
106
116
  if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
107
- if (head === 'skills') return writeClaudeSkill(decodeURIComponent(item), body.text, seenIn(body), dir);
108
- if (head === 'memory')
109
- return writeMemoryFile(projectOf(new URLSearchParams(search)), decodeURIComponent(item), body.text, dir);
110
- return writeClaudeSettings(item, body.text, seenIn(body), dir);
117
+ return writeItem(head, item, body.text, seenIn(body), search, dir);
111
118
  }
112
119
 
113
120
  async function created(req: IncomingMessage, path: string, dir: string): Promise<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
+ }
@@ -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.124"
2
+ "version": "0.1.0-beta.125"
3
3
  }