@stage-labs/metro 0.1.0-beta.41 → 0.1.0-beta.43
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/dist/api.js +19 -2
- package/dist/cli.js +11 -8
- package/dist/local.js +67 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +95 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-files.ts +321 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-url.ts +20 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-cli-api.ts +70 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +67 -22
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +3 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +7 -8
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +12 -2
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +36 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +11 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +312 -0
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/hosted-connectors.ts +0 -118
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-connectors-api.ts +0 -56
package/dist/api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { metroUrl, readRunTokens, readToken } from './store.js';
|
|
2
|
+
import { localAgents, localDaemonUp, localMcpServers, pickLocalAgent } from './local.js';
|
|
3
|
+
import { localUrl } from './runtime.js';
|
|
2
4
|
export class NotSignedIn extends Error {
|
|
3
5
|
}
|
|
4
6
|
const TIMEOUT_MS = 20_000;
|
|
@@ -115,13 +117,28 @@ export async function claimRuntime(code, label) {
|
|
|
115
117
|
throw new Error('metro returned an unexpected response');
|
|
116
118
|
return { token: body.token, agent: body.agent, label: body.label };
|
|
117
119
|
}
|
|
118
|
-
export async function mcpServers() {
|
|
120
|
+
export async function mcpServers(wanted) {
|
|
121
|
+
if (await localDaemonUp()) {
|
|
122
|
+
const agents = localAgents();
|
|
123
|
+
if (agents.length > 0)
|
|
124
|
+
return localMcpServers(pickLocalAgent(agents, wanted));
|
|
125
|
+
}
|
|
119
126
|
const body = await get('/api/cli/mcp');
|
|
120
127
|
if (!isRecord(body) || typeof body.json !== 'string')
|
|
121
128
|
throw new Error('metro returned an unexpected response');
|
|
122
129
|
return body.json;
|
|
123
130
|
}
|
|
124
|
-
export async function whoisAuthorized() {
|
|
131
|
+
export async function whoisAuthorized(wanted) {
|
|
132
|
+
if (await localDaemonUp()) {
|
|
133
|
+
const agents = localAgents();
|
|
134
|
+
if (agents.length > 0) {
|
|
135
|
+
const agent = pickLocalAgent(agents, wanted);
|
|
136
|
+
return { email: 'this machine', agent: agent.name, where: `${localUrl()} (local)` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ...(await hostedIdentity()), where: metroUrl() };
|
|
140
|
+
}
|
|
141
|
+
async function hostedIdentity() {
|
|
125
142
|
const body = await get('/api/cli/session');
|
|
126
143
|
if (!isRecord(body) ||
|
|
127
144
|
typeof body.email !== 'string' ||
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
3
|
import { claimCode, claimRuntime, mcpServers, NotSignedIn, whoisAuthorized, } from './api.js';
|
|
4
4
|
import { askSecret } from './prompt.js';
|
|
5
|
-
import { clearToken, credentialsPath,
|
|
5
|
+
import { clearToken, credentialsPath, metroWebUrl, writeToken, } from './store.js';
|
|
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, runTokenState, writeRunToken, } from './runtime.js';
|
|
@@ -32,8 +32,11 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
32
32
|
metro login [code]
|
|
33
33
|
authorize this machine for one agent with a code from its page
|
|
34
34
|
metro logout forget this machine's sign-in
|
|
35
|
-
metro whoami
|
|
36
|
-
|
|
35
|
+
metro whoami [agent]
|
|
36
|
+
print the account and agent this machine may read
|
|
37
|
+
metro mcp [agent]
|
|
38
|
+
print the mcpServers block of the agent's connectors; with a local daemon
|
|
39
|
+
running, its agent's connectors (name one if several)
|
|
37
40
|
metro plugin set up the Claude Code plugin (connector servers + /metro:login)
|
|
38
41
|
metro claude [args...]
|
|
39
42
|
open Claude Code with the metro channel; every argument is passed through
|
|
@@ -142,9 +145,9 @@ async function login(given) {
|
|
|
142
145
|
process.stderr.write('Claude Code plugin refreshed — new sessions have the connectors; ' +
|
|
143
146
|
'run /reload-plugins in any session already open.\n');
|
|
144
147
|
}
|
|
145
|
-
async function whoami() {
|
|
146
|
-
const { email, agent } = await whoisAuthorized();
|
|
147
|
-
process.stdout.write(`${email} · agent '${agent}' on ${
|
|
148
|
+
async function whoami(wanted) {
|
|
149
|
+
const { email, agent, where } = await whoisAuthorized(wanted);
|
|
150
|
+
process.stdout.write(`${email} · agent '${agent}' on ${where}\n`);
|
|
148
151
|
}
|
|
149
152
|
const HELP = new Set([undefined, 'help', '--help', '-h']);
|
|
150
153
|
const COMMANDS = {
|
|
@@ -158,7 +161,7 @@ const COMMANDS = {
|
|
|
158
161
|
return Promise.resolve(0);
|
|
159
162
|
},
|
|
160
163
|
whoami: async () => {
|
|
161
|
-
await whoami();
|
|
164
|
+
await whoami(process.argv[3]);
|
|
162
165
|
return 0;
|
|
163
166
|
},
|
|
164
167
|
start: () => start(process.argv.slice(3)),
|
|
@@ -168,7 +171,7 @@ const COMMANDS = {
|
|
|
168
171
|
logs: () => logs(process.argv.slice(3)),
|
|
169
172
|
tail: () => tailEvents(process.argv.slice(3)),
|
|
170
173
|
mcp: async () => {
|
|
171
|
-
process.stdout.write(`${await mcpServers()}\n`);
|
|
174
|
+
process.stdout.write(`${await mcpServers(process.argv[3])}\n`);
|
|
172
175
|
return 0;
|
|
173
176
|
},
|
|
174
177
|
plugin: installPlugin,
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { localUrl } from './runtime.js';
|
|
5
|
+
const PROBE_MS = 3_000;
|
|
6
|
+
const READ_MS = 20_000;
|
|
7
|
+
export function agentsDir() {
|
|
8
|
+
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
9
|
+
return explicit !== undefined && explicit !== '' ? explicit : join(homedir(), '.metro', 'agents');
|
|
10
|
+
}
|
|
11
|
+
export function localAgents(dir = agentsDir()) {
|
|
12
|
+
if (!existsSync(dir))
|
|
13
|
+
return [];
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
16
|
+
if (!entry.isDirectory())
|
|
17
|
+
continue;
|
|
18
|
+
const path = join(dir, entry.name, 'agent.json');
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
if (typeof file.id === 'string' && typeof file.name === 'string' && typeof file.key === 'string')
|
|
24
|
+
out.push({ id: file.id, name: file.name, key: file.key });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
31
|
+
}
|
|
32
|
+
export function pickLocalAgent(agents, wanted) {
|
|
33
|
+
if (wanted !== undefined && wanted !== '') {
|
|
34
|
+
const found = agents.find((a) => a.name === wanted || a.id === wanted);
|
|
35
|
+
if (found === undefined)
|
|
36
|
+
throw new Error(`no local agent named '${wanted}'`);
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
const only = agents[0];
|
|
40
|
+
if (only !== undefined && agents.length === 1)
|
|
41
|
+
return only;
|
|
42
|
+
throw new Error(agents.length === 0
|
|
43
|
+
? 'no agent on this machine yet'
|
|
44
|
+
: `several agents on this machine — name one: ${agents.map((a) => a.name).join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
export async function localDaemonUp(base = localUrl()) {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${base}/api/mode`, { signal: AbortSignal.timeout(PROBE_MS) });
|
|
49
|
+
const body = (await res.json());
|
|
50
|
+
return res.ok && body.mode === 'local';
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function localMcpServers(agent, base = localUrl()) {
|
|
57
|
+
const res = await fetch(`${base}/api/cli/mcp`, {
|
|
58
|
+
headers: { authorization: `Bearer ${agent.key}` },
|
|
59
|
+
signal: AbortSignal.timeout(READ_MS),
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
throw new Error(`the local daemon answered ${String(res.status)} for the connectors`);
|
|
63
|
+
const body = (await res.json());
|
|
64
|
+
if (typeof body.json !== 'string')
|
|
65
|
+
throw new Error('the local daemon returned an unexpected response');
|
|
66
|
+
return body.json;
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.43",
|
|
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": {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from './log.js';
|
|
3
|
+
import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
|
|
4
|
+
import { ApiError } from './api-error.js';
|
|
5
|
+
import {
|
|
6
|
+
claudeDir,
|
|
7
|
+
listClaudeProjects,
|
|
8
|
+
listClaudeSessions,
|
|
9
|
+
listMemory,
|
|
10
|
+
readMemoryFile,
|
|
11
|
+
readTranscript,
|
|
12
|
+
} from './claude-files.js';
|
|
13
|
+
|
|
14
|
+
const PREFIX = '/api/claude';
|
|
15
|
+
const PAGE = 100;
|
|
16
|
+
const PAGE_MAX = 500;
|
|
17
|
+
|
|
18
|
+
export interface ClaudeApiDeps {
|
|
19
|
+
authorize: (subject: string) => void;
|
|
20
|
+
dir?: () => string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function projectOf(query: URLSearchParams): string {
|
|
24
|
+
const project = query.get('project');
|
|
25
|
+
if (project === null || project === '') throw new ApiError('project is required', 400);
|
|
26
|
+
return project;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pageOf(query: URLSearchParams): { offset: number; limit: number } {
|
|
30
|
+
const offset = Math.max(0, Math.floor(Number(query.get('offset') ?? '0')) || 0);
|
|
31
|
+
const limit = Math.min(PAGE_MAX, Math.max(1, Math.floor(Number(query.get('limit') ?? String(PAGE))) || PAGE));
|
|
32
|
+
return { offset, limit };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Handler = (query: URLSearchParams, dir: string, item: string) => unknown;
|
|
36
|
+
|
|
37
|
+
const COLLECTIONS: Record<string, Handler> = {
|
|
38
|
+
projects: (_query, dir) => ({ projects: listClaudeProjects(dir) }),
|
|
39
|
+
sessions: (query, dir) => ({ sessions: listClaudeSessions(projectOf(query), dir) }),
|
|
40
|
+
memory: (query, dir) => listMemory(projectOf(query), dir),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const ITEMS: Record<string, Handler> = {
|
|
44
|
+
sessions: (query, dir, id) => {
|
|
45
|
+
const { offset, limit } = pageOf(query);
|
|
46
|
+
return readTranscript(projectOf(query), id, offset, limit, dir);
|
|
47
|
+
},
|
|
48
|
+
memory: (query, dir, name) => ({ name, content: readMemoryFile(projectOf(query), name, dir) }),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function answer(path: string, query: URLSearchParams, dir: string): unknown {
|
|
52
|
+
const rest = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
|
|
53
|
+
const [head = '', item] = rest;
|
|
54
|
+
const handler = rest.length === 1 ? COLLECTIONS[head] : rest.length === 2 ? ITEMS[head] : undefined;
|
|
55
|
+
if (handler === undefined) throw new ApiError('no such route', 404);
|
|
56
|
+
return handler(query, dir, item ?? '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function handleClaudeRequest(
|
|
60
|
+
req: IncomingMessage,
|
|
61
|
+
res: ServerResponse,
|
|
62
|
+
deps: ClaudeApiDeps,
|
|
63
|
+
): boolean {
|
|
64
|
+
const [path = '', search = ''] = (req.url ?? '').split('?');
|
|
65
|
+
if (path !== PREFIX && !path.startsWith(`${PREFIX}/`)) return false;
|
|
66
|
+
if (req.method === 'OPTIONS') {
|
|
67
|
+
res.writeHead(204, cors(req)).end();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
if (req.method !== 'GET') {
|
|
71
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const session = apiSession(req);
|
|
75
|
+
if (!session) {
|
|
76
|
+
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
Promise.resolve()
|
|
80
|
+
.then(() => {
|
|
81
|
+
deps.authorize(session.subject);
|
|
82
|
+
return answer(path, new URLSearchParams(search), (deps.dir ?? claudeDir)());
|
|
83
|
+
})
|
|
84
|
+
.then((body) => {
|
|
85
|
+
sendJson(req, res, 200, body);
|
|
86
|
+
})
|
|
87
|
+
.catch((err: unknown) => {
|
|
88
|
+
if (err instanceof ApiError) apiFailure(req, res, err, 'claude-api');
|
|
89
|
+
else {
|
|
90
|
+
log.warn({ err: errMsg(err) }, 'claude-api: request failed');
|
|
91
|
+
if (!res.headersSent) sendJson(req, res, 500, { error: 'claude-api failed' });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
createReadStream,
|
|
4
|
+
existsSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { createInterface } from 'node:readline';
|
|
13
|
+
import { ApiError } from './api-error.js';
|
|
14
|
+
|
|
15
|
+
const PROJECT_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
16
|
+
const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
|
|
17
|
+
const MEMORY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}\.md$/;
|
|
18
|
+
const EDGE_BYTES = 64 * 1024;
|
|
19
|
+
const TEXT_CAP = 20_000;
|
|
20
|
+
const RESULT_CAP = 8_000;
|
|
21
|
+
const INPUT_CAP = 4_000;
|
|
22
|
+
const FILE_CAP = 1_000_000;
|
|
23
|
+
const NOISE = ['<task-notification>', '<system-reminder>', '<local-command', '<command-name>'];
|
|
24
|
+
|
|
25
|
+
export function claudeDir(): string {
|
|
26
|
+
const explicit = process.env.METRO_CLAUDE_DIR?.trim();
|
|
27
|
+
if (explicit !== undefined && explicit !== '') return explicit;
|
|
28
|
+
const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
29
|
+
if (cfg !== undefined && cfg !== '') return cfg;
|
|
30
|
+
return join(homedir(), '.claude');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
34
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
35
|
+
|
|
36
|
+
const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
|
|
37
|
+
|
|
38
|
+
function safeName(value: string, re: RegExp, what: string): string {
|
|
39
|
+
if (!re.test(value) || value === '.' || value === '..')
|
|
40
|
+
throw new ApiError(`that is not a ${what}`, 400);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function projectDir(project: string, dir: string): string {
|
|
45
|
+
const path = join(dir, 'projects', safeName(project, PROJECT_RE, 'Claude project id'));
|
|
46
|
+
if (!existsSync(path)) throw new ApiError('no such Claude project', 404);
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseLines(text: string): Record<string, unknown>[] {
|
|
51
|
+
const out: Record<string, unknown>[] = [];
|
|
52
|
+
for (const line of text.split('\n')) {
|
|
53
|
+
if (line.trim() === '') continue;
|
|
54
|
+
try {
|
|
55
|
+
const parsed: unknown = JSON.parse(line);
|
|
56
|
+
if (isRecord(parsed)) out.push(parsed);
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readEdge(path: string, fromEnd: boolean): Record<string, unknown>[] {
|
|
65
|
+
const size = statSync(path).size;
|
|
66
|
+
const length = Math.min(EDGE_BYTES, size);
|
|
67
|
+
const fd = openSync(path, 'r');
|
|
68
|
+
try {
|
|
69
|
+
const buffer = Buffer.alloc(length);
|
|
70
|
+
readSync(fd, buffer, 0, length, fromEnd ? size - length : 0);
|
|
71
|
+
const text = buffer.toString('utf8');
|
|
72
|
+
const cut = fromEnd && length < size ? text.indexOf('\n') + 1 : 0;
|
|
73
|
+
return parseLines(text.slice(cut));
|
|
74
|
+
} finally {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ClaudeProject {
|
|
80
|
+
id: string;
|
|
81
|
+
cwd: string | null;
|
|
82
|
+
sessions: number;
|
|
83
|
+
lastActiveAt: string | null;
|
|
84
|
+
hasMemory: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sessionFiles(path: string): { name: string; mtime: number; size: number }[] {
|
|
88
|
+
return readdirSync(path)
|
|
89
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
90
|
+
.map((name) => {
|
|
91
|
+
const s = statSync(join(path, name));
|
|
92
|
+
return { name, mtime: s.mtimeMs, size: s.size };
|
|
93
|
+
})
|
|
94
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cwdOf(path: string): string | null {
|
|
98
|
+
for (const line of readEdge(path, false)) {
|
|
99
|
+
const cwd = str(line.cwd);
|
|
100
|
+
if (cwd !== null) return cwd;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function listClaudeProjects(dir = claudeDir()): ClaudeProject[] {
|
|
106
|
+
const root = join(dir, 'projects');
|
|
107
|
+
if (!existsSync(root)) return [];
|
|
108
|
+
return readdirSync(root, { withFileTypes: true })
|
|
109
|
+
.filter((entry) => entry.isDirectory() && PROJECT_RE.test(entry.name))
|
|
110
|
+
.map((entry) => {
|
|
111
|
+
const path = join(root, entry.name);
|
|
112
|
+
const files = sessionFiles(path);
|
|
113
|
+
const newest = files[0];
|
|
114
|
+
return {
|
|
115
|
+
id: entry.name,
|
|
116
|
+
cwd: newest === undefined ? null : cwdOf(join(path, newest.name)),
|
|
117
|
+
sessions: files.length,
|
|
118
|
+
lastActiveAt: newest === undefined ? null : new Date(newest.mtime).toISOString(),
|
|
119
|
+
hasMemory: existsSync(join(path, 'memory')),
|
|
120
|
+
};
|
|
121
|
+
})
|
|
122
|
+
.sort((a, b) => (b.lastActiveAt ?? '').localeCompare(a.lastActiveAt ?? ''));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ClaudeSession {
|
|
126
|
+
id: string;
|
|
127
|
+
title: string;
|
|
128
|
+
startedAt: string | null;
|
|
129
|
+
lastAt: string;
|
|
130
|
+
bytes: number;
|
|
131
|
+
gitBranch: string | null;
|
|
132
|
+
version: string | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function firstPrompt(lines: Record<string, unknown>[]): string | null {
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
if (line.type !== 'user' || line.isSidechain === true) continue;
|
|
138
|
+
const message = isRecord(line.message) ? line.message : {};
|
|
139
|
+
const text = promptText(message.content);
|
|
140
|
+
if (text !== null && !NOISE.some((n) => text.startsWith(n))) return text.slice(0, 120);
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function promptText(content: unknown): string | null {
|
|
146
|
+
if (typeof content === 'string') return content.trim() === '' ? null : content.trim();
|
|
147
|
+
if (!Array.isArray(content)) return null;
|
|
148
|
+
for (const block of content)
|
|
149
|
+
if (isRecord(block) && block.type === 'text') {
|
|
150
|
+
const text = str(block.text);
|
|
151
|
+
if (text !== null) return text.trim();
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sessionOf(path: string, id: string, mtime: number, size: number): ClaudeSession {
|
|
157
|
+
const head = readEdge(path, false);
|
|
158
|
+
const tail = readEdge(path, true);
|
|
159
|
+
const titled = [...tail].reverse().find((l) => l.type === 'ai-title' && str(l.aiTitle) !== null);
|
|
160
|
+
const meta = head.find((l) => l.type === 'user' || l.type === 'assistant') ?? {};
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
title: (titled === undefined ? null : str(titled.aiTitle)) ?? firstPrompt(head) ?? 'Untitled session',
|
|
164
|
+
startedAt: str(head.find((l) => str(l.timestamp) !== null)?.timestamp) ?? null,
|
|
165
|
+
lastAt: new Date(mtime).toISOString(),
|
|
166
|
+
bytes: size,
|
|
167
|
+
gitBranch: str(meta.gitBranch),
|
|
168
|
+
version: str(meta.version),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function listClaudeSessions(project: string, dir = claudeDir()): ClaudeSession[] {
|
|
173
|
+
const path = projectDir(project, dir);
|
|
174
|
+
return sessionFiles(path).map((f) =>
|
|
175
|
+
sessionOf(join(path, f.name), f.name.slice(0, -'.jsonl'.length), f.mtime, f.size),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type Block =
|
|
180
|
+
| { kind: 'text'; text: string }
|
|
181
|
+
| { kind: 'tool_use'; name: string; input: string }
|
|
182
|
+
| { kind: 'tool_result'; text: string; isError: boolean }
|
|
183
|
+
| { kind: 'thinking' }
|
|
184
|
+
| { kind: 'image' };
|
|
185
|
+
|
|
186
|
+
export interface TranscriptEntry {
|
|
187
|
+
uuid: string;
|
|
188
|
+
at: string | null;
|
|
189
|
+
role: 'user' | 'assistant';
|
|
190
|
+
blocks: Block[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface TranscriptPage {
|
|
194
|
+
entries: TranscriptEntry[];
|
|
195
|
+
total: number;
|
|
196
|
+
next: number | null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const cap = (text: string, max: number): string =>
|
|
200
|
+
text.length > max ? `${text.slice(0, max)}\n… (${String(text.length - max)} more characters)` : text;
|
|
201
|
+
|
|
202
|
+
function resultText(content: unknown): string {
|
|
203
|
+
if (typeof content === 'string') return content;
|
|
204
|
+
if (!Array.isArray(content)) return '';
|
|
205
|
+
return content
|
|
206
|
+
.map((b) => (isRecord(b) && b.type === 'text' ? (str(b.text) ?? '') : ''))
|
|
207
|
+
.filter((t) => t !== '')
|
|
208
|
+
.join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function textBlock(raw: Record<string, unknown>): Block | null {
|
|
212
|
+
const text = str(raw.text);
|
|
213
|
+
if (text === null || NOISE.some((n) => text.startsWith(n))) return null;
|
|
214
|
+
return { kind: 'text', text: cap(text, TEXT_CAP) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const BLOCKS: Record<string, (raw: Record<string, unknown>) => Block | null> = {
|
|
218
|
+
text: textBlock,
|
|
219
|
+
tool_use: (raw) => ({
|
|
220
|
+
kind: 'tool_use',
|
|
221
|
+
name: str(raw.name) ?? 'tool',
|
|
222
|
+
input: cap(JSON.stringify(raw.input ?? {}, null, 1), INPUT_CAP),
|
|
223
|
+
}),
|
|
224
|
+
tool_result: (raw) => ({
|
|
225
|
+
kind: 'tool_result',
|
|
226
|
+
text: cap(resultText(raw.content), RESULT_CAP),
|
|
227
|
+
isError: raw.is_error === true,
|
|
228
|
+
}),
|
|
229
|
+
thinking: () => ({ kind: 'thinking' }),
|
|
230
|
+
image: () => ({ kind: 'image' }),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
function blockOf(raw: unknown): Block | null {
|
|
234
|
+
if (!isRecord(raw) || typeof raw.type !== 'string') return null;
|
|
235
|
+
const make = BLOCKS[raw.type];
|
|
236
|
+
return make === undefined ? null : make(raw);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function entryOf(line: Record<string, unknown>): TranscriptEntry | null {
|
|
240
|
+
if ((line.type !== 'user' && line.type !== 'assistant') || line.isSidechain === true) return null;
|
|
241
|
+
const message = isRecord(line.message) ? line.message : {};
|
|
242
|
+
const content = message.content;
|
|
243
|
+
const blocks =
|
|
244
|
+
typeof content === 'string'
|
|
245
|
+
? [blockOf({ type: 'text', text: content })]
|
|
246
|
+
: Array.isArray(content)
|
|
247
|
+
? content.map(blockOf)
|
|
248
|
+
: [];
|
|
249
|
+
const kept = blocks.filter((b): b is Block => b !== null);
|
|
250
|
+
if (kept.length === 0) return null;
|
|
251
|
+
return { uuid: str(line.uuid) ?? '', at: str(line.timestamp), role: line.type, blocks: kept };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function readTranscript(
|
|
255
|
+
project: string,
|
|
256
|
+
session: string,
|
|
257
|
+
offset: number,
|
|
258
|
+
limit: number,
|
|
259
|
+
dir = claudeDir(),
|
|
260
|
+
): Promise<TranscriptPage> {
|
|
261
|
+
const path = join(projectDir(project, dir), `${safeName(session, SESSION_RE, 'session id')}.jsonl`);
|
|
262
|
+
if (!existsSync(path)) throw new ApiError('no such session', 404);
|
|
263
|
+
const entries: TranscriptEntry[] = [];
|
|
264
|
+
let total = 0;
|
|
265
|
+
const lines = createInterface({ input: createReadStream(path, { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
266
|
+
for await (const line of lines) {
|
|
267
|
+
const parsed = parseLines(line)[0];
|
|
268
|
+
const entry = parsed === undefined ? null : entryOf(parsed);
|
|
269
|
+
if (entry === null) continue;
|
|
270
|
+
if (total >= offset && entries.length < limit) entries.push(entry);
|
|
271
|
+
total += 1;
|
|
272
|
+
}
|
|
273
|
+
return { entries, total, next: offset + limit < total ? offset + limit : null };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface MemoryFile {
|
|
277
|
+
name: string;
|
|
278
|
+
bytes: number;
|
|
279
|
+
modifiedAt: string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface MemoryListing {
|
|
283
|
+
files: MemoryFile[];
|
|
284
|
+
index: string | null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function memoryDir(project: string, dir: string): string {
|
|
288
|
+
return join(projectDir(project, dir), 'memory');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function readCapped(path: string): string {
|
|
292
|
+
const size = statSync(path).size;
|
|
293
|
+
const fd = openSync(path, 'r');
|
|
294
|
+
try {
|
|
295
|
+
const buffer = Buffer.alloc(Math.min(size, FILE_CAP));
|
|
296
|
+
readSync(fd, buffer, 0, buffer.length, 0);
|
|
297
|
+
return size > FILE_CAP ? `${buffer.toString('utf8')}\n\n… truncated` : buffer.toString('utf8');
|
|
298
|
+
} finally {
|
|
299
|
+
closeSync(fd);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function listMemory(project: string, dir = claudeDir()): MemoryListing {
|
|
304
|
+
const path = memoryDir(project, dir);
|
|
305
|
+
if (!existsSync(path)) return { files: [], index: null };
|
|
306
|
+
const files = readdirSync(path)
|
|
307
|
+
.filter((name) => MEMORY_RE.test(name) && name !== 'MEMORY.md')
|
|
308
|
+
.map((name) => {
|
|
309
|
+
const s = statSync(join(path, name));
|
|
310
|
+
return { name, bytes: s.size, modifiedAt: s.mtime.toISOString() };
|
|
311
|
+
})
|
|
312
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
313
|
+
const index = join(path, 'MEMORY.md');
|
|
314
|
+
return { files, index: existsSync(index) ? readCapped(index) : null };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function readMemoryFile(project: string, name: string, dir = claudeDir()): string {
|
|
318
|
+
const path = join(memoryDir(project, dir), safeName(name, MEMORY_RE, 'memory file name'));
|
|
319
|
+
if (!existsSync(path)) throw new ApiError('no such memory file', 404);
|
|
320
|
+
return readCapped(path);
|
|
321
|
+
}
|
|
@@ -9,6 +9,12 @@ export class ConnectorNotMcp extends ConnectorVerifyError {}
|
|
|
9
9
|
export const POLICY_MESSAGE =
|
|
10
10
|
'Metro connects from its own server, so it cannot reach a URL on your machine. localhost and private addresses are not usable as connectors.';
|
|
11
11
|
|
|
12
|
+
let localAllowed = false;
|
|
13
|
+
|
|
14
|
+
export function allowLocalConnectors(on: boolean): void {
|
|
15
|
+
localAllowed = on;
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
13
19
|
const PRIVATE_SUFFIX = /(?:^|\.)(?:localhost|local|internal|flycast)$/;
|
|
14
20
|
|
|
@@ -22,6 +28,19 @@ function hostAllowed(host: string): boolean {
|
|
|
22
28
|
return !PRIVATE_SUFFIX.test(host);
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
function schemeAllowed(url: URL): boolean {
|
|
32
|
+
return url.protocol === 'https:' || (localAllowed && url.protocol === 'http:');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function refuseShape(url: URL): void {
|
|
36
|
+
if (!schemeAllowed(url)) throw refused('a connector url must start with https://');
|
|
37
|
+
if (url.username !== '' || url.password !== '')
|
|
38
|
+
throw refused('a connector url must not carry a user:password');
|
|
39
|
+
if (url.hash !== '')
|
|
40
|
+
throw refused('a connector url must not carry a #fragment');
|
|
41
|
+
if (!localAllowed && !hostAllowed(url.hostname.toLowerCase())) throw refused(POLICY_MESSAGE);
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
export function parseConnectorUrl(raw: unknown): URL {
|
|
26
45
|
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
27
46
|
if (text === '') throw refused('a connector url is required');
|
|
@@ -31,13 +50,7 @@ export function parseConnectorUrl(raw: unknown): URL {
|
|
|
31
50
|
} catch {
|
|
32
51
|
throw refused('that is not a valid url');
|
|
33
52
|
}
|
|
34
|
-
|
|
35
|
-
throw refused('a connector url must start with https://');
|
|
36
|
-
if (url.username !== '' || url.password !== '')
|
|
37
|
-
throw refused('a connector url must not carry a user:password');
|
|
38
|
-
if (url.hash !== '')
|
|
39
|
-
throw refused('a connector url must not carry a #fragment');
|
|
40
|
-
if (!hostAllowed(url.hostname.toLowerCase())) throw refused(POLICY_MESSAGE);
|
|
53
|
+
refuseShape(url);
|
|
41
54
|
return url;
|
|
42
55
|
}
|
|
43
56
|
|