@stage-labs/metro 0.1.0-beta.15 → 0.1.0-beta.16
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 +58 -0
- package/dist/cli.js +69 -0
- package/dist/login.js +102 -0
- package/dist/store.js +63 -0
- package/dist/update.js +33 -0
- package/dist/version.js +97 -0
- package/package.json +12 -27
- package/LICENSE +0 -21
- package/README.md +0 -139
- package/dist/broker/claims.js +0 -144
- package/dist/broker/history-stream.js +0 -149
- package/dist/cli/config.js +0 -179
- package/dist/cli/index.js +0 -175
- package/dist/cli/messenger-api.js +0 -214
- package/dist/cli/messenger-transcribe.js +0 -43
- package/dist/cli/messenger-uploads.js +0 -116
- package/dist/cli/monitor-api.js +0 -205
- package/dist/cli/tail.js +0 -95
- package/dist/cli/util.js +0 -72
- package/dist/cli/webhook.js +0 -181
- package/dist/codex-rc/client.js +0 -225
- package/dist/codex-rc/protocol.js +0 -38
- package/dist/dispatcher/server.js +0 -122
- package/dist/dispatcher.js +0 -97
- package/dist/history.js +0 -143
- package/dist/ipc.js +0 -90
- package/dist/lines.js +0 -54
- package/dist/local-identity.js +0 -80
- package/dist/log.js +0 -15
- package/dist/paths.js +0 -114
- package/dist/trains/protocol.js +0 -99
- package/dist/trains/supervisor.js +0 -210
- package/dist/tunnel.js +0 -102
- package/docs/broker.md +0 -166
- package/docs/monitor.md +0 -241
- package/docs/uri-scheme.md +0 -127
- package/examples/README.md +0 -32
- package/examples/telegram.ts +0 -121
- package/skills/metro/SKILL.md +0 -110
package/dist/api.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { metroUrl, readToken } from './store.js';
|
|
2
|
+
export class NotSignedIn extends Error {
|
|
3
|
+
}
|
|
4
|
+
const TIMEOUT_MS = 20_000;
|
|
5
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
6
|
+
export function carriesSecretsSafely(base) {
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
url = new URL(base);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (url.protocol === 'https:')
|
|
15
|
+
return true;
|
|
16
|
+
return url.protocol === 'http:' && LOOPBACK.has(url.hostname);
|
|
17
|
+
}
|
|
18
|
+
function tokenOrThrow() {
|
|
19
|
+
if (!carriesSecretsSafely(metroUrl()))
|
|
20
|
+
throw new Error(`refusing to send your session to ${metroUrl()} in the clear — use https, or a loopback address`);
|
|
21
|
+
const token = readToken();
|
|
22
|
+
if (token === null)
|
|
23
|
+
throw new NotSignedIn('not signed in — run `metro login` first');
|
|
24
|
+
return token;
|
|
25
|
+
}
|
|
26
|
+
async function get(path, presented) {
|
|
27
|
+
const auth = presented ?? tokenOrThrow();
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await fetch(`${metroUrl()}${path}`, {
|
|
31
|
+
headers: { authorization: `Bearer ${auth}` },
|
|
32
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error(`could not reach ${metroUrl()}`);
|
|
37
|
+
}
|
|
38
|
+
if (res.status === 401)
|
|
39
|
+
throw new NotSignedIn('that sign-in has expired — run `metro login` again');
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error(`metro answered ${String(res.status)}`);
|
|
42
|
+
return res.json();
|
|
43
|
+
}
|
|
44
|
+
function isRecord(value) {
|
|
45
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
export async function mcpServers() {
|
|
48
|
+
const body = await get('/api/connectors');
|
|
49
|
+
if (!isRecord(body) || typeof body.json !== 'string')
|
|
50
|
+
throw new Error('metro returned an unexpected response');
|
|
51
|
+
return body.json;
|
|
52
|
+
}
|
|
53
|
+
export async function sessionEmail(presented) {
|
|
54
|
+
const body = await get('/api/session', presented);
|
|
55
|
+
if (!isRecord(body) || typeof body.email !== 'string')
|
|
56
|
+
throw new Error('metro returned an unexpected response');
|
|
57
|
+
return body.email;
|
|
58
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mcpServers, NotSignedIn, sessionEmail } from './api.js';
|
|
3
|
+
import { signIn } from './login.js';
|
|
4
|
+
import { clearToken, credentialsPath, metroUrl, writeToken } from './store.js';
|
|
5
|
+
import { update } from './update.js';
|
|
6
|
+
import { currentVersion } from './version.js';
|
|
7
|
+
const USAGE = `metro — the command line for your MCP connectors
|
|
8
|
+
|
|
9
|
+
metro login sign in to metro in your browser
|
|
10
|
+
metro logout forget this machine's sign-in
|
|
11
|
+
metro whoami print the account this machine is signed in as
|
|
12
|
+
metro mcp print an mcpServers block for every connector
|
|
13
|
+
metro update update to the newest published version
|
|
14
|
+
metro version print this CLI's version
|
|
15
|
+
|
|
16
|
+
Start Claude Code with all of them, without writing them to disk:
|
|
17
|
+
|
|
18
|
+
claude --mcp-config <(metro mcp)
|
|
19
|
+
|
|
20
|
+
METRO_URL the metro to talk to (default https://mcp.metro.box)
|
|
21
|
+
METRO_TOKEN use this session instead of the stored one
|
|
22
|
+
`;
|
|
23
|
+
async function login() {
|
|
24
|
+
const token = await signIn();
|
|
25
|
+
const email = await sessionEmail(token).catch(() => '');
|
|
26
|
+
writeToken(token);
|
|
27
|
+
process.stderr.write(`Signed in${email === '' ? '' : ` as ${email}`}. Stored in ${credentialsPath()}\n`);
|
|
28
|
+
}
|
|
29
|
+
async function whoami() {
|
|
30
|
+
process.stdout.write(`${await sessionEmail()} on ${metroUrl()}\n`);
|
|
31
|
+
}
|
|
32
|
+
const HELP = new Set([undefined, 'help', '--help', '-h']);
|
|
33
|
+
const COMMANDS = {
|
|
34
|
+
login: async () => {
|
|
35
|
+
await login();
|
|
36
|
+
return 0;
|
|
37
|
+
},
|
|
38
|
+
logout: async () => {
|
|
39
|
+
clearToken();
|
|
40
|
+
process.stderr.write('Signed out.\n');
|
|
41
|
+
return Promise.resolve(0);
|
|
42
|
+
},
|
|
43
|
+
whoami: async () => {
|
|
44
|
+
await whoami();
|
|
45
|
+
return 0;
|
|
46
|
+
},
|
|
47
|
+
mcp: async () => {
|
|
48
|
+
process.stdout.write(`${await mcpServers()}\n`);
|
|
49
|
+
return 0;
|
|
50
|
+
},
|
|
51
|
+
update,
|
|
52
|
+
version: async () => {
|
|
53
|
+
process.stdout.write(`${currentVersion()}\n`);
|
|
54
|
+
return Promise.resolve(0);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
async function run(command) {
|
|
58
|
+
const handler = command === undefined ? undefined : COMMANDS[command];
|
|
59
|
+
if (handler !== undefined)
|
|
60
|
+
return handler();
|
|
61
|
+
process.stderr.write(USAGE);
|
|
62
|
+
return Promise.resolve(HELP.has(command) ? 0 : 1);
|
|
63
|
+
}
|
|
64
|
+
const code = await run(process.argv[2]).catch((err) => {
|
|
65
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
66
|
+
process.stderr.write(`metro: ${message}\n`);
|
|
67
|
+
return err instanceof NotSignedIn ? 2 : 1;
|
|
68
|
+
});
|
|
69
|
+
process.exit(code);
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { metroUrl } from './store.js';
|
|
5
|
+
const TIMEOUT_MS = 5 * 60_000;
|
|
6
|
+
const MAX_BODY = 16 * 1024;
|
|
7
|
+
const PAGE = `<!doctype html><meta charset="utf-8"><title>Metro</title>
|
|
8
|
+
<body style="font:16px system-ui;padding:3rem;color:#222">
|
|
9
|
+
<p id="m">Finishing sign-in…</p>
|
|
10
|
+
<script>
|
|
11
|
+
const nonce = new URLSearchParams(location.search).get('s') || '';
|
|
12
|
+
const body = new URLSearchParams(location.hash.replace(/^#/, ''));
|
|
13
|
+
body.set('s', nonce);
|
|
14
|
+
fetch('/token', { method: 'POST', body }).then(
|
|
15
|
+
() => { document.getElementById('m').textContent = 'Signed in. You can close this tab.'; },
|
|
16
|
+
() => { document.getElementById('m').textContent = 'Could not reach the metro CLI.'; },
|
|
17
|
+
);
|
|
18
|
+
</script>`;
|
|
19
|
+
function browserCommand(url) {
|
|
20
|
+
if (process.platform === 'darwin')
|
|
21
|
+
return { cmd: 'open', args: [url] };
|
|
22
|
+
if (process.platform === 'win32')
|
|
23
|
+
return { cmd: 'cmd', args: ['/c', 'start', '', url] };
|
|
24
|
+
return { cmd: 'xdg-open', args: [url] };
|
|
25
|
+
}
|
|
26
|
+
function openBrowser(url) {
|
|
27
|
+
if (process.env.METRO_NO_BROWSER === '1')
|
|
28
|
+
return;
|
|
29
|
+
const { cmd, args } = browserCommand(url);
|
|
30
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
31
|
+
child.on('error', () => undefined);
|
|
32
|
+
child.unref();
|
|
33
|
+
}
|
|
34
|
+
async function readBody(req) {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
for await (const chunk of req) {
|
|
38
|
+
const buf = chunk;
|
|
39
|
+
size += buf.length;
|
|
40
|
+
if (size > MAX_BODY)
|
|
41
|
+
throw new Error('sign-in body too large');
|
|
42
|
+
chunks.push(buf);
|
|
43
|
+
}
|
|
44
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
45
|
+
}
|
|
46
|
+
export function sessionFrom(body, nonce) {
|
|
47
|
+
const params = new URLSearchParams(body);
|
|
48
|
+
if (params.get('s') !== nonce)
|
|
49
|
+
return '';
|
|
50
|
+
return params.get('session') ?? '';
|
|
51
|
+
}
|
|
52
|
+
export function signIn() {
|
|
53
|
+
const nonce = randomBytes(16).toString('base64url');
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
let settled = false;
|
|
56
|
+
const finish = (err, token) => {
|
|
57
|
+
if (settled)
|
|
58
|
+
return;
|
|
59
|
+
settled = true;
|
|
60
|
+
server.close();
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
if (err !== null)
|
|
63
|
+
reject(err);
|
|
64
|
+
else
|
|
65
|
+
resolve(token ?? '');
|
|
66
|
+
};
|
|
67
|
+
const accept = (res, body) => {
|
|
68
|
+
const session = sessionFrom(body, nonce);
|
|
69
|
+
if (session === '') {
|
|
70
|
+
res.writeHead(400).end();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
res.writeHead(204).end();
|
|
74
|
+
finish(null, session);
|
|
75
|
+
};
|
|
76
|
+
const server = createServer((req, res) => {
|
|
77
|
+
if (req.method !== 'POST' || !(req.url ?? '').startsWith('/token')) {
|
|
78
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
readBody(req).then((body) => {
|
|
82
|
+
accept(res, body);
|
|
83
|
+
}, () => {
|
|
84
|
+
res.writeHead(400).end();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
finish(new Error('sign-in timed out'));
|
|
89
|
+
}, TIMEOUT_MS);
|
|
90
|
+
timer.unref();
|
|
91
|
+
server.on('error', (err) => {
|
|
92
|
+
finish(err);
|
|
93
|
+
});
|
|
94
|
+
server.listen(0, '127.0.0.1', () => {
|
|
95
|
+
const { port } = server.address();
|
|
96
|
+
const returnTo = `http://127.0.0.1:${String(port)}/callback?s=${nonce}`;
|
|
97
|
+
const url = `${metroUrl()}/auth/google/start?return_to=${encodeURIComponent(returnTo)}`;
|
|
98
|
+
process.stderr.write(`Opening ${url}\n`);
|
|
99
|
+
openBrowser(url);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const DEFAULT_BASE = 'https://mcp.metro.box';
|
|
5
|
+
export function metroUrl() {
|
|
6
|
+
const raw = process.env.METRO_URL?.trim();
|
|
7
|
+
return raw === undefined || raw === '' ? DEFAULT_BASE : raw.replace(/\/+$/, '');
|
|
8
|
+
}
|
|
9
|
+
function configDir() {
|
|
10
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
11
|
+
const base = xdg === undefined || xdg === '' ? join(homedir(), '.config') : xdg;
|
|
12
|
+
return join(base, 'metro');
|
|
13
|
+
}
|
|
14
|
+
export function credentialsPath() {
|
|
15
|
+
return join(configDir(), 'credentials.json');
|
|
16
|
+
}
|
|
17
|
+
function readStored() {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = readFileSync(credentialsPath(), 'utf8');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
33
|
+
return null;
|
|
34
|
+
const { token, url } = parsed;
|
|
35
|
+
if (typeof token !== 'string' || token === '')
|
|
36
|
+
return null;
|
|
37
|
+
return { token, url: typeof url === 'string' ? url : DEFAULT_BASE };
|
|
38
|
+
}
|
|
39
|
+
export function readToken() {
|
|
40
|
+
const fromEnv = process.env.METRO_TOKEN?.trim();
|
|
41
|
+
if (fromEnv !== undefined && fromEnv !== '')
|
|
42
|
+
return fromEnv;
|
|
43
|
+
const stored = readStored();
|
|
44
|
+
if (stored === null)
|
|
45
|
+
return null;
|
|
46
|
+
return stored.url === metroUrl() ? stored.token : null;
|
|
47
|
+
}
|
|
48
|
+
export function writeToken(token) {
|
|
49
|
+
const path = credentialsPath();
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
51
|
+
writeFileSync(path, `${JSON.stringify({ token, url: metroUrl() })}\n`, {
|
|
52
|
+
mode: 0o600,
|
|
53
|
+
});
|
|
54
|
+
chmodSync(path, 0o600);
|
|
55
|
+
}
|
|
56
|
+
export function clearToken() {
|
|
57
|
+
try {
|
|
58
|
+
rmSync(credentialsPath());
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { currentVersion, isNewer, PACKAGE_NAME, publishedVersion, } from './version.js';
|
|
3
|
+
export function managerFor(location) {
|
|
4
|
+
return location.includes('/.bun/') ? 'bun' : 'npm';
|
|
5
|
+
}
|
|
6
|
+
export function installArgs(manager, version) {
|
|
7
|
+
const spec = `${PACKAGE_NAME}@${version}`;
|
|
8
|
+
return manager === 'bun'
|
|
9
|
+
? ['add', '--global', spec]
|
|
10
|
+
: ['install', '--global', spec];
|
|
11
|
+
}
|
|
12
|
+
export async function update() {
|
|
13
|
+
const current = currentVersion();
|
|
14
|
+
const newest = await publishedVersion();
|
|
15
|
+
if (newest === '') {
|
|
16
|
+
process.stderr.write('npm published no version this CLI understands\n');
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
if (!isNewer(newest, current)) {
|
|
20
|
+
process.stderr.write(`metro ${current} is the newest published version\n`);
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
const manager = managerFor(import.meta.url);
|
|
24
|
+
const args = installArgs(manager, newest);
|
|
25
|
+
process.stderr.write(`Updating metro ${current} to ${newest} with ${manager}\n`);
|
|
26
|
+
const run = spawnSync(manager, args, { stdio: 'inherit' });
|
|
27
|
+
if (run.error !== undefined || run.status !== 0) {
|
|
28
|
+
process.stderr.write(`That did not work. Run it yourself:\n ${manager} ${args.join(' ')}\n`);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
process.stderr.write(`metro is now ${newest}\n`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
export const PACKAGE_NAME = '@stage-labs/metro';
|
|
3
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
4
|
+
const TIMEOUT_MS = 15_000;
|
|
5
|
+
export function parseVersion(raw) {
|
|
6
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(raw.trim());
|
|
7
|
+
if (match === null)
|
|
8
|
+
return null;
|
|
9
|
+
const core = [match[1], match[2], match[3]].map((n) => Number(n));
|
|
10
|
+
const pre = match[4] === undefined
|
|
11
|
+
? []
|
|
12
|
+
: match[4]
|
|
13
|
+
.split('.')
|
|
14
|
+
.map((part) => (/^\d+$/.test(part) ? Number(part) : part));
|
|
15
|
+
return { core, pre };
|
|
16
|
+
}
|
|
17
|
+
function comparePart(left, right) {
|
|
18
|
+
if (left === undefined)
|
|
19
|
+
return -1;
|
|
20
|
+
if (right === undefined)
|
|
21
|
+
return 1;
|
|
22
|
+
if (left === right)
|
|
23
|
+
return 0;
|
|
24
|
+
if (typeof left === 'number' && typeof right === 'number')
|
|
25
|
+
return left < right ? -1 : 1;
|
|
26
|
+
if (typeof left === 'number')
|
|
27
|
+
return -1;
|
|
28
|
+
if (typeof right === 'number')
|
|
29
|
+
return 1;
|
|
30
|
+
return left < right ? -1 : 1;
|
|
31
|
+
}
|
|
32
|
+
function comparePre(a, b) {
|
|
33
|
+
if (a.length === 0 && b.length === 0)
|
|
34
|
+
return 0;
|
|
35
|
+
if (a.length === 0)
|
|
36
|
+
return 1;
|
|
37
|
+
if (b.length === 0)
|
|
38
|
+
return -1;
|
|
39
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
40
|
+
const cmp = comparePart(a[i], b[i]);
|
|
41
|
+
if (cmp !== 0)
|
|
42
|
+
return cmp;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
export function compareVersions(a, b) {
|
|
47
|
+
const left = parseVersion(a);
|
|
48
|
+
const right = parseVersion(b);
|
|
49
|
+
if (left === null || right === null)
|
|
50
|
+
return 0;
|
|
51
|
+
for (let i = 0; i < 3; i += 1) {
|
|
52
|
+
const l = left.core[i] ?? 0;
|
|
53
|
+
const r = right.core[i] ?? 0;
|
|
54
|
+
if (l !== r)
|
|
55
|
+
return l < r ? -1 : 1;
|
|
56
|
+
}
|
|
57
|
+
return comparePre(left.pre, right.pre);
|
|
58
|
+
}
|
|
59
|
+
export function isNewer(candidate, current) {
|
|
60
|
+
return compareVersions(candidate, current) > 0;
|
|
61
|
+
}
|
|
62
|
+
export function currentVersion() {
|
|
63
|
+
const path = new URL('../package.json', import.meta.url);
|
|
64
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
65
|
+
return typeof raw.version === 'string' ? raw.version : '0.0.0';
|
|
66
|
+
}
|
|
67
|
+
export function newestOf(tags) {
|
|
68
|
+
let best = '';
|
|
69
|
+
for (const value of Object.values(tags)) {
|
|
70
|
+
if (typeof value !== 'string' || parseVersion(value) === null)
|
|
71
|
+
continue;
|
|
72
|
+
if (best === '' || isNewer(value, best))
|
|
73
|
+
best = value;
|
|
74
|
+
}
|
|
75
|
+
return best;
|
|
76
|
+
}
|
|
77
|
+
export async function publishedVersion() {
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetch(`${REGISTRY}/${PACKAGE_NAME.replace('/', '%2F')}`, {
|
|
81
|
+
headers: { accept: 'application/json' },
|
|
82
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error('could not reach the npm registry');
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok)
|
|
89
|
+
throw new Error(`npm answered ${String(res.status)}`);
|
|
90
|
+
const body = await res.json();
|
|
91
|
+
if (typeof body !== 'object' || body === null)
|
|
92
|
+
throw new Error('npm returned an unexpected response');
|
|
93
|
+
const tags = body['dist-tags'];
|
|
94
|
+
if (typeof tags !== 'object' || tags === null)
|
|
95
|
+
throw new Error('npm returned no dist-tags');
|
|
96
|
+
return newestOf(tags);
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
4
|
-
"
|
|
5
|
-
"description": "Event-interception wire. Supervises train subprocesses in ~/.metro/trains/, multiplexes their JSON event stream onto stdout, and routes outbound action calls back via stdin. Per-platform code is written by the agent (or user) as train scripts — metro core is pure transport.",
|
|
3
|
+
"version": "0.1.0-beta.16",
|
|
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.",
|
|
6
5
|
"license": "MIT",
|
|
7
6
|
"repository": {
|
|
8
7
|
"type": "git",
|
|
@@ -15,39 +14,25 @@
|
|
|
15
14
|
"node": ">=22"
|
|
16
15
|
},
|
|
17
16
|
"bin": {
|
|
18
|
-
"metro": "
|
|
17
|
+
"metro": "dist/cli.js"
|
|
19
18
|
},
|
|
20
19
|
"files": [
|
|
21
|
-
"dist"
|
|
22
|
-
"docs",
|
|
23
|
-
"examples",
|
|
24
|
-
"skills",
|
|
25
|
-
"README.md",
|
|
26
|
-
"LICENSE"
|
|
20
|
+
"dist"
|
|
27
21
|
],
|
|
28
22
|
"publishConfig": {
|
|
29
|
-
"access": "public"
|
|
23
|
+
"access": "public",
|
|
24
|
+
"tag": "beta"
|
|
30
25
|
},
|
|
31
26
|
"scripts": {
|
|
32
27
|
"build": "tsc",
|
|
33
28
|
"prepublishOnly": "tsc",
|
|
34
|
-
"
|
|
35
|
-
"lint:fix": "eslint src/ examples/ --fix",
|
|
36
|
-
"typecheck": "tsc --noEmit",
|
|
37
|
-
"test": "METRO_STATE_DIR=\"$(mktemp -d /tmp/metro-test.XXXXXX)\" bun test test/"
|
|
38
|
-
},
|
|
39
|
-
"dependencies": {
|
|
40
|
-
"pino": "^9.5.0",
|
|
41
|
-
"pino-pretty": "^13.1.3",
|
|
42
|
-
"ws": "^8.20.0"
|
|
29
|
+
"test": "tsc --noEmit && bun test test/"
|
|
43
30
|
},
|
|
44
31
|
"devDependencies": {
|
|
45
|
-
"@
|
|
46
|
-
"@types/node": "^22.10.0",
|
|
47
|
-
"@types/ws": "^8.18.1",
|
|
32
|
+
"@stage-labs/config": "0.1.0-beta.2",
|
|
48
33
|
"eslint": "^10.3.0",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
34
|
+
"knip": "^6.15.0",
|
|
35
|
+
"madge": "^8.0.0",
|
|
36
|
+
"typescript": "^5"
|
|
37
|
+
}
|
|
53
38
|
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Stage Labs
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
package/README.md
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
# Metro
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@stage-labs/metro)
|
|
4
|
-
[](https://github.com/bonustrack/metro)
|
|
5
|
-
|
|
6
|
-
> **Event-interception wire. Supervises train subprocesses, multiplexes their stdout into one
|
|
7
|
-
> JSON event stream, routes outbound action calls back via stdin. Per-platform code lives in
|
|
8
|
-
> train scripts under `~/.metro/trains/` — outside this repo — written by the user (or agent)
|
|
9
|
-
> on demand.**
|
|
10
|
-
|
|
11
|
-
Metro is not a framework with platform connectors. Metro is the wire.
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
[Claude Code session]
|
|
15
|
-
|
|
16
|
-
$ metro & # backgrounded
|
|
17
|
-
$ Monitor( … metro's stdout … )
|
|
18
|
-
|
|
19
|
-
>>> {"kind":"inbound","station":"discord","line":"metro://discord/123…","message_id":"9876",
|
|
20
|
-
"text":"@metro 5xx spike on /v1/sync — look?",
|
|
21
|
-
"payload":{"channelId":"123…","guildId":"456…","content":"<@…> 5xx spike…",
|
|
22
|
-
"mentions":{"users":["<bot-id>"],"roles":[],"everyone":false},…}}
|
|
23
|
-
|
|
24
|
-
[I'd grep services/sync.ts, then…]
|
|
25
|
-
Bash: metro call discord send '{"line":"metro://discord/123…","text":"three deploys in the last 24h…","replyTo":"9876"}'
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
You own streaming, tool calls, and reply timing. Metro is the wire.
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
## Quickstart
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
|
|
36
|
-
|
|
37
|
-
# One-time train setup (Telegram — no npm deps needed; uses native fetch)
|
|
38
|
-
mkdir -p ~/.metro && cd ~/.metro && bun init -y
|
|
39
|
-
cp $(npm root -g)/@stage-labs/metro/examples/telegram.ts ~/.metro/trains/
|
|
40
|
-
echo 'TELEGRAM_BOT_TOKEN=your-token' >> ~/.metro/.env
|
|
41
|
-
|
|
42
|
-
metro doctor # verify
|
|
43
|
-
metro # run the daemon
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
For Discord, copy the same `telegram.ts` and port it — swap the API base for
|
|
47
|
-
`https://discord.com/api/v10` with `Authorization: Bot $TOKEN`, install
|
|
48
|
-
`discord.js` for the gateway (`cd ~/.metro && bun add discord.js`), and keep
|
|
49
|
-
the same envelope + `op:"call"` ↔ `op:"response"` protocol. See
|
|
50
|
-
[`examples/README.md`](./examples/README.md) for the wire-format reference.
|
|
51
|
-
|
|
52
|
-
Requires **Bun ≥ 1.3** (trains run under `bun run`). Metro core itself works under Node ≥ 22.
|
|
53
|
-
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
## Architecture
|
|
57
|
-
|
|
58
|
-
```
|
|
59
|
-
~/.metro/trains/discord.ts ──> stdout JSON ──┐
|
|
60
|
-
~/.metro/trains/telegram.ts ─> stdout JSON ──┤
|
|
61
|
-
~/.metro/trains/<anything>.ts ─> stdout ─────┼──> metro daemon ──> stdout (Monitor / Codex push)
|
|
62
|
-
│ history.jsonl
|
|
63
|
-
HTTP /wh/<id> (builtin webhook receiver) ───┤
|
|
64
|
-
IPC `notify` (builtin cross-user channel) ─┘
|
|
65
|
-
|
|
66
|
-
metro call discord send {…} ──> IPC ──> daemon ──> train stdin ──> response ──> CLI stdout
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
Every event metro emits is a `HistoryEntry`. Trains produce the full envelope; metro
|
|
70
|
-
enriches `id`/`display` and appends to `history.jsonl`. Outbound action calls are
|
|
71
|
-
train-defined — metro core knows the protocol (`{op:"call", id, action, args}` → `{op:"response", id, result|error}`),
|
|
72
|
-
not what any specific action does.
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
|
-
## Train protocol
|
|
77
|
-
|
|
78
|
-
**Inbound (train → metro stdout)** — one JSON line per event (wire fields are `snake_case`):
|
|
79
|
-
|
|
80
|
-
```json
|
|
81
|
-
{"kind":"inbound","station":"discord","line":"metro://discord/123","from":"metro://discord/user/456","from_name":"alice","message_id":"789","text":"hi","is_private":false,"ts":"2026-05-17T18:00:00Z","payload":{...}}
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
**Outbound (metro → train stdin)** — one JSON line per action call:
|
|
85
|
-
|
|
86
|
-
```json
|
|
87
|
-
{"op":"call","id":"req_abc","action":"send","args":{"line":"metro://discord/123","text":"hi"}}
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
Train responds on stdout:
|
|
91
|
-
|
|
92
|
-
```json
|
|
93
|
-
{"op":"response","id":"req_abc","result":{"messageId":"999"}}
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
See [`examples/telegram.ts`](./examples/telegram.ts) (a self-contained ~110 LOC reference train) and [`examples/README.md`](./examples/README.md) for the full protocol + Discord port notes.
|
|
97
|
-
|
|
98
|
-
---
|
|
99
|
-
|
|
100
|
-
## CLI
|
|
101
|
-
|
|
102
|
-
```
|
|
103
|
-
metro # start the daemon (foreground)
|
|
104
|
-
metro trains list # supervised trains + state
|
|
105
|
-
metro trains new <name> # scaffold ~/.metro/trains/<name>.ts from the example
|
|
106
|
-
metro trains restart <name> # kill + respawn a train (resets backoff)
|
|
107
|
-
metro call <train> <action> <args> # forward an action call; args = JSON / @file / - / string
|
|
108
|
-
metro tail [--as=<user-uri>] [--follow] # subscribe to the event log; claim-aware
|
|
109
|
-
metro history [--limit=50] [--line=…] # recent history (newest first), filterable
|
|
110
|
-
metro lines # recently-seen conversations
|
|
111
|
-
metro claim <line> # take exclusive ownership of a line
|
|
112
|
-
metro release <line> # release
|
|
113
|
-
metro claims # print the claims map
|
|
114
|
-
metro webhook add <label> [--secret=…] # add an HTTP receive endpoint
|
|
115
|
-
metro webhook list | remove <id> # manage endpoints
|
|
116
|
-
metro tunnel setup <name> <hostname> # configure a Cloudflare named tunnel
|
|
117
|
-
metro tunnel status # show current tunnel config
|
|
118
|
-
metro setup [skill [clear]] # status; or install/remove the skill into ~/.claude / ~/.codex
|
|
119
|
-
metro doctor # health check (trains, deps, tunnel, webhooks, env vars)
|
|
120
|
-
metro update # upgrade in place
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
No more `metro send / reply / edit / react / download / fetch` — outbound is always
|
|
124
|
-
`metro call <train> <action> <args>`, with action names defined by the train.
|
|
125
|
-
|
|
126
|
-
---
|
|
127
|
-
|
|
128
|
-
## State
|
|
129
|
-
|
|
130
|
-
- `~/.metro/trains/` — your train scripts
|
|
131
|
-
- `~/.metro/.env` — your credentials (trains read these)
|
|
132
|
-
- `~/.metro/package.json` — `bun add` here for train deps
|
|
133
|
-
- `$METRO_STATE_DIR` (default `~/.cache/metro/`) — history, claims, cursors, monitor data
|
|
134
|
-
|
|
135
|
-
---
|
|
136
|
-
|
|
137
|
-
## License
|
|
138
|
-
|
|
139
|
-
MIT
|