@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.
@@ -1,144 +0,0 @@
1
- /** Claims map: per-line owner registry under an O_EXCL lockfile. */
2
- import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { log } from '../log.js';
5
- import { STATE_DIR } from '../paths.js';
6
- import { Line } from '../lines.js';
7
- export const CLAIMS_FILE = join(STATE_DIR, 'claims.json');
8
- const CLAIMS_LOCK = join(STATE_DIR, 'claims.json.lock');
9
- export const HISTORY_FILE = join(STATE_DIR, 'history.jsonl');
10
- /** Read claims.json. Returns empty map if missing or malformed (retries once on race). */
11
- export function readClaims() {
12
- if (!existsSync(CLAIMS_FILE))
13
- return {};
14
- for (let attempt = 0; attempt < 2; attempt++) {
15
- try {
16
- return JSON.parse(readFileSync(CLAIMS_FILE, 'utf8'));
17
- }
18
- catch { /* race with writer — retry once */ }
19
- }
20
- log.warn({ path: CLAIMS_FILE }, 'claims: malformed, treating as empty');
21
- return {};
22
- }
23
- /** Mutate claims under an O_EXCL lockfile. Throws if another writer holds the lock past timeout. */
24
- function withClaimsLock(fn) {
25
- const deadline = Date.now() + 2_000;
26
- while (true) {
27
- try {
28
- closeSync(openSync(CLAIMS_LOCK, 'wx'));
29
- break;
30
- }
31
- catch (err) {
32
- if (err.code !== 'EEXIST')
33
- throw err;
34
- if (Date.now() > deadline)
35
- throw new Error('claims.json: lock contention (held >2s)');
36
- }
37
- }
38
- try {
39
- const next = readClaims();
40
- const result = fn(next);
41
- /** atomic publish: tmpfile + rename so readers never see a half-written file */
42
- const tmp = `${CLAIMS_FILE}.tmp.${process.pid}`;
43
- writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
44
- renameSync(tmp, CLAIMS_FILE);
45
- return result;
46
- }
47
- finally {
48
- try {
49
- unlinkSync(CLAIMS_LOCK);
50
- }
51
- catch { /* ignore */ }
52
- }
53
- }
54
- export function claimLine(line, owner) {
55
- return withClaimsLock(m => { m[line] = owner; return m; });
56
- }
57
- export function releaseLine(line) {
58
- return withClaimsLock(m => {
59
- const released = line in m;
60
- delete m[line];
61
- return { released, claims: m };
62
- });
63
- }
64
- /** Classify a chat line as DM/group/unknown for the auto-claim group-skip rule. */
65
- /** TG: chatId<0⇒group. Discord: peek payload.guildId on most-recent inbound. Claude/Codex⇒dm. */
66
- export function classifyLine(line) {
67
- const station = Line.station(line);
68
- if (station === 'telegram') {
69
- const parsed = Line.parseTelegram(line);
70
- if (!parsed)
71
- return 'unknown';
72
- return parsed.chatId < 0 ? 'group' : 'dm';
73
- }
74
- if (station === 'claude' || station === 'codex')
75
- return 'dm';
76
- if (station === 'webhook')
77
- return 'group';
78
- if (station === 'discord') {
79
- /** Lazy tail-scan of history.jsonl to avoid a static dep on the history filter helpers. */
80
- const recent = readRecentInbound(line);
81
- if (!recent)
82
- return 'unknown';
83
- const payload = recent.payload;
84
- if (!payload || !('guildId' in payload)) {
85
- /** Older entries may not have a guildId — fall back to the `to` field: DMs route to a user URI. */
86
- if (recent.to && recent.to !== recent.line)
87
- return 'dm';
88
- return 'unknown';
89
- }
90
- return payload.guildId == null ? 'dm' : 'group';
91
- }
92
- return 'unknown';
93
- }
94
- /** Most-recent inbound on `line`. Walks `history.jsonl` from the tail; returns undefined if missing. */
95
- function readRecentInbound(line) {
96
- if (!existsSync(HISTORY_FILE))
97
- return undefined;
98
- const lines = readFileSync(HISTORY_FILE, 'utf8').split('\n');
99
- for (let i = lines.length - 1; i >= 0; i--) {
100
- if (!lines[i].trim())
101
- continue;
102
- try {
103
- const e = JSON.parse(lines[i]);
104
- if (e.line === line && e.kind === 'inbound')
105
- return e;
106
- }
107
- catch { /* skip */ }
108
- }
109
- return undefined;
110
- }
111
- /** Per-line decision: should auto-claim run on a successful outbound? */
112
- function shouldAutoClaim(line, kind) {
113
- const station = Line.station(line);
114
- /** Webhook lines are a broadcast stream — claiming one is a footgun. */
115
- if (station === 'webhook')
116
- return { ok: false, reason: 'webhook' };
117
- /** Claude/Codex cross-user lines are 1:1 by construction — always safe. */
118
- if (station === 'claude' || station === 'codex')
119
- return { ok: true };
120
- if (kind === 'group')
121
- return { ok: false, reason: 'group' };
122
- return { ok: true };
123
- }
124
- export function tryAutoClaim(line, owner, opts = {}) {
125
- if (!opts.force) {
126
- const decision = shouldAutoClaim(line, opts.lineKind ?? 'unknown');
127
- if (!decision.ok)
128
- return { status: decision.reason, line };
129
- }
130
- try {
131
- return withClaimsLock(m => {
132
- const existing = m[line];
133
- if (existing && existing !== owner)
134
- return { status: 'skipped', existing };
135
- if (existing === owner)
136
- return { status: 'kept', owner };
137
- m[line] = owner;
138
- return { status: 'claimed', owner };
139
- });
140
- }
141
- catch (err) {
142
- return { status: 'error', error: err.message };
143
- }
144
- }
@@ -1,147 +0,0 @@
1
- /** Per-user byte-offset cursors over history.jsonl + claim-aware mode filter. */
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, watch, writeFileSync, } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { errMsg, log } from '../log.js';
5
- import { STATE_DIR } from '../paths.js';
6
- import { HISTORY_FILE, readClaims } from './claims.js';
7
- const CURSORS_DIR = join(STATE_DIR, 'cursors');
8
- /** Filename-safe slug for a participant URI. `metro://claude/user/9bfc…` → `claude-user-9bfc…`. */
9
- export function userSlug(uri) {
10
- return uri.replace(/^metro:\/+/, '').replace(/[^A-Za-z0-9_.-]/g, '-');
11
- }
12
- /** Cursor key for a tail invocation. Derived from the *effective mode*, NOT from `userSelf()`. */
13
- /** Keeps `--all` / `--unclaimed` from trampling a `--as=<id>` cursor in a CLAUDECODE shell. */
14
- /** Keys: `--as=<id>`→slug(id); `+--strict`→slug+`--strict`; `--unclaimed`→`_unclaimed`; `--all`→`_all`. */
15
- /** The `_` prefix can't collide with a userSlug (always has a station prefix like `claude-user-…`). */
16
- /** `--include-webhooks` adds `--with-webhooks` so toggling mid-stream doesn't re-emit/skip events. */
17
- export function cursorKey(mode, self, opts = {}) {
18
- if (mode === 'all')
19
- return '_all';
20
- if (mode === 'unclaimed')
21
- return '_unclaimed';
22
- if (!self)
23
- return null;
24
- const base = userSlug(self);
25
- const suffix = mode === 'mine-only' ? '--strict' : '';
26
- const webhooks = opts.includeWebhooks ? '--with-webhooks' : '';
27
- return `${base}${suffix}${webhooks}`;
28
- }
29
- const cursorPath = (key) => join(CURSORS_DIR, key);
30
- export function readCursor(key) {
31
- const p = cursorPath(key);
32
- if (!existsSync(p))
33
- return 0;
34
- const n = Number(readFileSync(p, 'utf8').trim());
35
- return Number.isFinite(n) && n >= 0 ? n : 0;
36
- }
37
- export function writeCursor(key, offset) {
38
- mkdirSync(CURSORS_DIR, { recursive: true });
39
- const p = cursorPath(key);
40
- const tmp = `${p}.tmp.${process.pid}`;
41
- writeFileSync(tmp, String(offset));
42
- renameSync(tmp, p);
43
- }
44
- /** Byte size of history.jsonl right now (for `--since=tail`). */
45
- export function historySize() {
46
- if (!existsSync(HISTORY_FILE))
47
- return 0;
48
- try {
49
- return readFileSync(HISTORY_FILE).length;
50
- }
51
- catch {
52
- return 0;
53
- }
54
- }
55
- /** Yield each complete JSONL line from `offset` to EOF; the returned offset is the position right after the `\n`. */
56
- export function* readEntriesFrom(offset) {
57
- if (!existsSync(HISTORY_FILE))
58
- return;
59
- const fd = openSync(HISTORY_FILE, 'r');
60
- try {
61
- const chunk = Buffer.alloc(64 * 1024);
62
- let pending = Buffer.alloc(0);
63
- let pos = offset;
64
- while (true) {
65
- const n = readSync(fd, chunk, 0, chunk.length, pos);
66
- if (n === 0)
67
- break;
68
- pending = Buffer.concat([pending, chunk.subarray(0, n)]);
69
- pos += n;
70
- let nl;
71
- while ((nl = pending.indexOf(0x0a)) !== -1) {
72
- const raw = pending.subarray(0, nl).toString('utf8').trim();
73
- pending = pending.subarray(nl + 1);
74
- if (!raw)
75
- continue;
76
- try {
77
- const entry = JSON.parse(raw);
78
- /** offsetAfter = read-cursor in file - bytes still in pending buffer */
79
- yield { entry, offset: pos - pending.length };
80
- }
81
- catch (err) {
82
- log.warn({ err: errMsg(err) }, 'broker: skipped malformed history line');
83
- }
84
- }
85
- }
86
- }
87
- finally {
88
- closeSync(fd);
89
- }
90
- }
91
- /**
92
- * Claim-aware filter. Webhooks excluded from personal modes unless `includeWebhooks`.
93
- */
94
- export function passesMode(event, mode, self, claims, opts = {}) {
95
- if (self && event.to === self)
96
- return true;
97
- if (mode === 'all')
98
- return true;
99
- const isWebhook = event.station === 'webhook';
100
- if (mode === 'unclaimed')
101
- return !claims[event.line];
102
- /** webhooks are filtered out of personal modes unless opted in */
103
- if (isWebhook && !opts.includeWebhooks)
104
- return false;
105
- const owner = claims[event.line];
106
- if (mode === 'mine-only')
107
- return owner === self;
108
- return !owner || owner === self; /** mode === 'mine-or-unclaimed' */
109
- }
110
- /** Drain matching entries from `offset` to EOF, returning the new offset. */
111
- /** Caller `onEntry` may return `true` to stop draining early (e.g. tail --limit). */
112
- export function drainTail(offset, opts, onEntry) {
113
- const claims = readClaims();
114
- for (const { entry, offset: next } of readEntriesFrom(offset)) {
115
- offset = next;
116
- if (opts.chatFilter && entry.line !== opts.chatFilter)
117
- continue;
118
- if (opts.stationFilter && entry.station !== opts.stationFilter)
119
- continue;
120
- if (!passesMode(entry, opts.mode, opts.self, claims, { includeWebhooks: opts.includeWebhooks }))
121
- continue;
122
- if (onEntry(entry) === true)
123
- return offset;
124
- }
125
- return offset;
126
- }
127
- /** Follow history.jsonl: drain on change + poll backstop (macOS fs.watch coalesces). */
128
- /** Caller invokes the returned `stop()` to clean up the watcher/timer. */
129
- export function followTail(startOffset, opts, onEntry, pollMs) {
130
- let offset = startOffset;
131
- const tick = () => { offset = drainTail(offset, opts, onEntry); };
132
- let watcher = null;
133
- try {
134
- watcher = watch(HISTORY_FILE, () => tick());
135
- }
136
- catch { /* file may not exist yet */ }
137
- const poll = setInterval(tick, pollMs);
138
- return () => {
139
- clearInterval(poll);
140
- if (watcher) {
141
- try {
142
- watcher.close();
143
- }
144
- catch { /* ignore */ }
145
- }
146
- };
147
- }
@@ -1,179 +0,0 @@
1
- /** `metro setup` / `metro doctor` / `metro update` / `metro setup skill`. */
2
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
3
- import { spawn } from 'node:child_process';
4
- import { homedir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- import pkg from '../../package.json' with { type: 'json' };
8
- import { errMsg } from '../log.js';
9
- import { CONFIG_ENV_FILE, loadMetroEnv, STATE_DIR } from '../paths.js';
10
- import { TRAINS_DIR } from '../trains/supervisor.js';
11
- import { listEndpoints, loadTunnelConfig, webhookPort } from '../tunnel.js';
12
- import { emit, exitErr, isJson, writeJson } from './util.js';
13
- const RUNTIME_DIRS = {
14
- 'claude-code': join(homedir(), '.claude', 'skills', 'metro'),
15
- codex: join(homedir(), '.codex', 'skills', 'metro'),
16
- };
17
- const skillDest = (r) => join(RUNTIME_DIRS[r], 'SKILL.md');
18
- /** dist/cli/config.js → <package-root>/skills/metro/SKILL.md */
19
- const bundledSkill = () => join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills', 'metro', 'SKILL.md');
20
- const skillStatus = () => ({
21
- 'claude-code': existsSync(skillDest('claude-code')), codex: existsSync(skillDest('codex')),
22
- });
23
- async function cmdSetupSkill(p, f) {
24
- const sub = p[0];
25
- if (sub === 'clear') {
26
- const removed = [];
27
- for (const r of Object.keys(RUNTIME_DIRS)) {
28
- const path = skillDest(r);
29
- if (existsSync(path)) {
30
- try {
31
- unlinkSync(path);
32
- removed.push(path);
33
- }
34
- catch { /* ignore */ }
35
- }
36
- }
37
- return emit(f, removed.length ? `removed metro skill from ${removed.join(', ')}` : 'no installed skill found', { ok: true, removed });
38
- }
39
- if (sub && sub !== 'install')
40
- throw exitErr(`unknown skill subcommand '${sub}' (try: install, clear)`, 1);
41
- const src = bundledSkill();
42
- if (!existsSync(src))
43
- throw exitErr(`bundled SKILL.md missing at ${src} (broken install?)`, 2);
44
- const installed = [];
45
- for (const r of Object.keys(RUNTIME_DIRS)) {
46
- if (!existsSync(join(homedir(), r === 'claude-code' ? '.claude' : '.codex')))
47
- continue;
48
- const dest = skillDest(r);
49
- try {
50
- mkdirSync(RUNTIME_DIRS[r], { recursive: true });
51
- copyFileSync(src, dest);
52
- installed.push(dest);
53
- }
54
- catch (err) {
55
- throw exitErr(`failed to install skill for ${r}: ${errMsg(err)}`, 2);
56
- }
57
- }
58
- if (!installed.length)
59
- throw exitErr('no user runtime detected (~/.claude or ~/.codex). Install one and rerun.', 2);
60
- emit(f, `installed metro skill → ${installed.join(', ')}`, { ok: true, installed });
61
- }
62
- /** Scan ~/.metro/trains/*.{ts,js,mjs} for `process.env.<NAME>` refs; report set/unset status. */
63
- function envCheck() {
64
- if (!existsSync(TRAINS_DIR))
65
- return [];
66
- const names = new Set();
67
- for (const f of readdirSync(TRAINS_DIR).filter(n => /\.(ts|js|mjs)$/.test(n) && !/^[._]/.test(n))) {
68
- try {
69
- const src = readFileSync(join(TRAINS_DIR, f), 'utf8');
70
- for (const m of src.matchAll(/process\.env\.([A-Z][A-Z0-9_]*)/g))
71
- names.add(m[1]);
72
- }
73
- catch { /* ignore */ }
74
- }
75
- /** METRO_* are internal — don't surface them as "missing credentials". */
76
- const interesting = [...names].filter(n => !n.startsWith('METRO_')).sort();
77
- if (!interesting.length)
78
- return [];
79
- const set = interesting.filter(n => process.env[n]);
80
- const missing = interesting.filter(n => !process.env[n]);
81
- return [{ name: 'env-vars', ok: missing.length ? false : true,
82
- detail: `set: ${set.join(', ') || '(none)'}${missing.length ? ` · missing: ${missing.join(', ')}` : ''}` }];
83
- }
84
- /* ──────────── setup / doctor / update ──────────── */
85
- export async function cmdSetup(p, f) {
86
- const [sub] = p;
87
- if (sub === 'skill')
88
- return cmdSetupSkill(p.slice(1), f);
89
- if (sub)
90
- throw new Error(`unknown setup subcommand '${sub}' (try: skill)`);
91
- loadMetroEnv();
92
- const cfgExists = existsSync(CONFIG_ENV_FILE), trainsExists = existsSync(TRAINS_DIR);
93
- if (isJson(f))
94
- return writeJson({
95
- version: pkg.version,
96
- config_env_file: CONFIG_ENV_FILE, config_env_exists: cfgExists,
97
- trains_dir: TRAINS_DIR, trains_dir_exists: trainsExists,
98
- });
99
- process.stdout.write(`metro ${pkg.version}\n\nconfig dir: ${CONFIG_ENV_FILE}${cfgExists ? '' : ' (not yet written)'}\n`
100
- + `trains dir: ${TRAINS_DIR}${trainsExists ? '' : ' (will be created on first run)'}\n\n`
101
- + 'Get started: see @stage-labs/metro/examples; drop train scripts in ~/.metro/trains/.\n');
102
- }
103
- export async function cmdDoctor(_, f) {
104
- loadMetroEnv();
105
- const checks = [];
106
- if (!existsSync(TRAINS_DIR)) {
107
- checks.push({ name: 'trains', ok: null, detail: `${TRAINS_DIR} (not created — \`mkdir -p\` it and drop in train files)` });
108
- }
109
- else {
110
- const files = readdirSync(TRAINS_DIR).filter(n => /\.(ts|js|mjs)$/.test(n) && !/^[._]/.test(n));
111
- checks.push({ name: 'trains', ok: files.length > 0,
112
- detail: files.length ? `${files.length} train${files.length === 1 ? '' : 's'}: ${files.join(', ')}` : '(empty — no trains configured)' });
113
- }
114
- const metroPkg = join(homedir(), '.metro', 'package.json');
115
- checks.push({ name: 'trains-pkg', ok: existsSync(metroPkg) ? true : null,
116
- detail: existsSync(metroPkg) ? metroPkg : `${metroPkg} not found (run \`cd ~/.metro && bun init\` if any train needs deps)` });
117
- const lockFile = join(STATE_DIR, '.tail-lock');
118
- if (!existsSync(lockFile))
119
- checks.push({ name: 'dispatcher', ok: null, detail: 'not running' });
120
- else
121
- try {
122
- const pid = Number(readFileSync(lockFile, 'utf8').trim());
123
- if (!Number.isInteger(pid) || pid <= 0)
124
- throw new Error('invalid pid');
125
- process.kill(pid, 0);
126
- checks.push({ name: 'dispatcher', ok: true, detail: `running (pid ${pid})` });
127
- }
128
- catch {
129
- checks.push({ name: 'dispatcher', ok: null, detail: 'stale lockfile (auto-reclaims)' });
130
- }
131
- checks.push({ name: 'codex-rc', ok: null,
132
- detail: process.env.METRO_CODEX_RC ? `push enabled → ${process.env.METRO_CODEX_RC}`
133
- : 'not configured (set METRO_CODEX_RC=ws://… to enable Codex push)' });
134
- const tunnel = loadTunnelConfig();
135
- checks.push({ name: 'tunnel', ok: tunnel ? true : null,
136
- detail: tunnel ? `${tunnel.hostname} → 127.0.0.1:${webhookPort()}`
137
- : 'not configured (run `metro tunnel setup <name> <hostname>`)' });
138
- const eps = listEndpoints();
139
- checks.push({ name: 'webhooks', ok: eps.length ? true : null,
140
- detail: eps.length ? `${eps.length} endpoint${eps.length === 1 ? '' : 's'}: ${eps.map(e => e.label).join(', ')}`
141
- : 'none (run `metro webhook add <label>`)' });
142
- for (const c of envCheck())
143
- checks.push(c);
144
- const installed = Object.entries(skillStatus()).filter(([, ok]) => ok).map(([r]) => r);
145
- checks.push({ name: 'skill', ok: installed.length ? true : null,
146
- detail: installed.length ? `installed for ${installed.join(', ')}` : 'not installed (run `metro setup skill`)' });
147
- if (isJson(f))
148
- return writeJson({ checks });
149
- process.stdout.write('metro doctor\n\n');
150
- for (const c of checks) {
151
- const mark = c.ok === true ? '✓' : c.ok === false ? '✗' : '–';
152
- process.stdout.write(` ${mark} ${c.name.padEnd(15)} ${c.detail}\n`);
153
- }
154
- process.stdout.write('\n');
155
- if (checks.some(c => c.ok === false))
156
- throw exitErr('one or more checks failed', 3);
157
- }
158
- export async function cmdUpdate(_, f) {
159
- const tag = pkg.version.includes('-') ? 'beta' : 'latest';
160
- const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
161
- if (!res.ok)
162
- throw new Error(`npm registry: ${res.status}`);
163
- const latest = (await res.json())['dist-tags']?.[tag];
164
- if (!latest)
165
- throw new Error(`no '${tag}' dist-tag for @stage-labs/metro`);
166
- if (latest === pkg.version) {
167
- return emit(f, `already on ${pkg.version} (latest ${tag})`, { ok: true, current: pkg.version, latest, upgraded: false });
168
- }
169
- const argv1 = process.argv[1] ?? '', spec = `@stage-labs/metro@${tag}`;
170
- const argv = argv1.includes('/.bun/') || argv1.includes('\\bun\\') ? ['bun', 'add', '-g', spec]
171
- : argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
172
- : ['npm', 'install', '-g', spec];
173
- emit(f, `metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}`, { ok: true, current: pkg.version, latest, command: argv.join(' ') });
174
- await new Promise((resolve, reject) => {
175
- const child = spawn(argv[0], argv.slice(1), { stdio: isJson(f) ? 'ignore' : 'inherit' });
176
- child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`)));
177
- child.on('error', reject);
178
- });
179
- }
package/dist/cli/index.js DELETED
@@ -1,150 +0,0 @@
1
- #!/usr/bin/env bun
2
- /** Metro CLI: parses argv, dispatches to subcommands. Bun runtime required (uses Bun.spawn for trains). */
3
- import pkg from '../../package.json' with { type: 'json' };
4
- import { errMsg } from '../log.js';
5
- import { listLines, loadMetroEnv } from '../paths.js';
6
- import { readHistory } from '../history.js';
7
- import { cmdDoctor, cmdSetup, cmdUpdate } from './config.js';
8
- import { cmdClaim, cmdClaims, cmdRelease, cmdTail } from './tail.js';
9
- import { cmdCall, cmdTrains, cmdTunnel, cmdWebhook } from './webhook.js';
10
- import { flagOne, isJson, parseArgs, writeJson, } from './util.js';
11
- const USAGE = `metro — event-interception wire. Trains in ~/.metro/trains/ produce events;
12
- metro multiplexes them onto stdout. Outbound action calls flow back via \`metro call\`.
13
-
14
- Usage:
15
- metro Run the dispatcher (emits JSON events on stdout).
16
- metro setup Print config status (credentials are owned by trains).
17
- metro setup skill [clear] Install/remove the metro skill into ~/.claude / ~/.codex.
18
- metro doctor Health check.
19
- metro lines List recently-seen conversations.
20
- metro trains [list] List supervised trains (running, pid, fail count).
21
- metro trains restart <name> Kill + respawn a train (resets backoff).
22
- metro trains new <name> Scaffold ~/.metro/trains/<name>.ts from the example.
23
- metro call <train> <action> [args] Forward an action call to a train via its stdin.
24
- [args] is JSON, '@file', '-' (stdin), or a bare string.
25
- metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
26
- Read the universal message log (newest first).
27
- metro tail [--as=<user-uri>] [--follow] [--strict | --unclaimed | --all] [--include-webhooks]
28
- [--chat=<line>] [--station=…] [--since=<offset|tail>] [--limit=N]
29
- Subscribe to the event log; claim-aware by default.
30
- metro claim <line> [--as=<user-uri>] Take exclusive ownership of a line.
31
- metro release <line> Release a line (it returns to broadcast).
32
- metro claims Print the current claims map.
33
- metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
34
- metro webhook list | remove <id> List or remove webhook endpoints.
35
- metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel.
36
- metro tunnel status Show current tunnel config.
37
- metro update Upgrade in place.
38
- metro --version | --help
39
-
40
- Trains: place \`<name>.ts\` files in ~/.metro/trains/. See \`@stage-labs/metro/examples\`.
41
- Lines: metro://<station>/<path>. Multi-line args: pipe on stdin where supported.
42
- Exit codes: 0 success · 1 usage · 2 config · 3 upstream
43
- `;
44
- async function cmdLines(_, f) {
45
- loadMetroEnv();
46
- const rows = listLines()
47
- .map(({ line, entry }) => ({ line, name: entry.name ?? null, lastSeenAt: entry.lastSeenAt ?? null }))
48
- .sort((a, b) => (b.lastSeenAt ?? '').localeCompare(a.lastSeenAt ?? ''));
49
- if (isJson(f))
50
- return writeJson({ lines: rows });
51
- if (!rows.length)
52
- return void process.stdout.write('metro lines\n\n (none yet — start the dispatcher and send a message)\n\n');
53
- const widest = Math.max(...rows.map(r => r.line.length));
54
- process.stdout.write('metro lines\n\n');
55
- for (const r of rows) {
56
- const when = r.lastSeenAt ? humanAgo(r.lastSeenAt) : '—';
57
- const tag = r.name ? ` ${r.name.slice(0, 40)}${r.name.length > 40 ? '…' : ''}` : '';
58
- process.stdout.write(` ${when.padEnd(10)} ${r.line.padEnd(widest)}${tag}\n`);
59
- }
60
- process.stdout.write('\n');
61
- }
62
- function humanAgo(iso) {
63
- const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
64
- if (sec < 60)
65
- return `${sec}s ago`;
66
- if (sec < 3600)
67
- return `${Math.floor(sec / 60)}m ago`;
68
- if (sec < 86400)
69
- return `${Math.floor(sec / 3600)}h ago`;
70
- return `${Math.floor(sec / 86400)}d ago`;
71
- }
72
- async function cmdHistory(_, f) {
73
- loadMetroEnv();
74
- const since = flagOne(f, 'since');
75
- const entries = readHistory({
76
- line: flagOne(f, 'line'),
77
- station: flagOne(f, 'station'),
78
- kind: flagOne(f, 'kind'),
79
- from: flagOne(f, 'from'),
80
- textContains: flagOne(f, 'text'),
81
- since: since ? new Date(since) : undefined,
82
- limit: Number(flagOne(f, 'limit')) || 50,
83
- });
84
- if (isJson(f))
85
- return writeJson({ entries });
86
- if (!entries.length) {
87
- process.stdout.write('(no matching history entries)\n');
88
- return;
89
- }
90
- process.stdout.write('time id kind from → to body\n');
91
- for (const e of entries.reverse()) {
92
- const ts = e.ts.slice(11, 19);
93
- const from = pad(fmtActor(e.from, e.fromName), 24);
94
- const to = pad(fmtActor(e.to), 24);
95
- const body = e.text ?? (e.emoji ? `[react ${e.emoji}]` : '');
96
- const text = body.length > 60 ? body.slice(0, 59) + '…' : body;
97
- process.stdout.write(`${ts} ${e.id.padEnd(12)} ${e.kind.padEnd(12)} ${from} → ${to} ${text}\n`);
98
- }
99
- }
100
- /** Compact display: fromName if known; else `station:@<id>` (user) or `station:<id>`. */
101
- function fmtActor(uri, name) {
102
- if (name)
103
- return name;
104
- const m = uri.match(/^metro:\/\/([^/]+)(?:\/(?:(user)\/)?(.*))?$/);
105
- if (!m)
106
- return uri;
107
- const [, station, kind, rest] = m;
108
- if (kind === 'user')
109
- return `${station}:@${shortId(rest ?? '')}`;
110
- return rest ? `${station}:${shortId(rest)}` : station;
111
- }
112
- const shortId = (s) => s.length <= 12 ? s : `${s.slice(0, 5)}…${s.slice(-4)}`;
113
- const pad = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
114
- const COMMANDS = {
115
- setup: cmdSetup, doctor: cmdDoctor, lines: cmdLines,
116
- call: cmdCall, trains: cmdTrains,
117
- webhook: cmdWebhook, tunnel: cmdTunnel,
118
- history: cmdHistory, tail: cmdTail,
119
- claim: cmdClaim, release: cmdRelease, claims: cmdClaims,
120
- update: cmdUpdate,
121
- };
122
- async function main() {
123
- const cmd = process.argv[2];
124
- if (cmd === '--version' || cmd === '-v')
125
- return void process.stdout.write(`${pkg.version}\n`);
126
- if (cmd === '--help' || cmd === '-h')
127
- return void process.stdout.write(USAGE);
128
- if (!cmd) {
129
- await import('../dispatcher.js');
130
- return;
131
- }
132
- const handler = COMMANDS[cmd];
133
- if (!handler) {
134
- process.stderr.write(`unknown command '${cmd}'\n\n${USAGE}`);
135
- process.exit(1);
136
- }
137
- const { positional, flags } = parseArgs(process.argv.slice(3));
138
- try {
139
- await handler(positional, flags);
140
- }
141
- catch (err) {
142
- const code = err.code;
143
- if (isJson(flags))
144
- writeJson({ ok: false, error: errMsg(err), code: code ?? 1 });
145
- else
146
- process.stderr.write(`error: ${errMsg(err)}\n`);
147
- process.exit(typeof code === 'number' ? code : 1);
148
- }
149
- }
150
- await main();