@stage-labs/metro 0.1.0-beta.14 → 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 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
+ }
@@ -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.14",
4
- "private": false,
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": "./dist/cli/index.js"
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
- "lint": "eslint src/ examples/",
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
- "@types/bun": "^1.2.0",
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
- "typescript": "^5",
50
- "typescript-eslint": "^8.59.2"
51
- },
52
- "packageManager": "bun@1.3.9"
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
- [![npm](https://img.shields.io/npm/v/@stage-labs/metro/beta?label=npm&color=cb3837)](https://www.npmjs.com/package/@stage-labs/metro)
4
- [![lines of code](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.codetabs.com%2Fv1%2Floc%2F%3Fgithub%3Dbonustrack%2Fmetro%26ignored%3Dapps%2Ctest&query=%24%5B0%5D.linesOfCode&label=lines%20of%20TypeScript&color=blue)](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