@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.15
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/README.md +76 -189
- package/dist/broker/claims.js +144 -0
- package/dist/{broker.js → broker/history-stream.js} +46 -99
- package/dist/cli/config.js +115 -121
- package/dist/cli/index.js +51 -64
- package/dist/cli/messenger-api.js +214 -0
- package/dist/cli/messenger-transcribe.js +43 -0
- package/dist/cli/messenger-uploads.js +116 -0
- package/dist/cli/monitor-api.js +205 -0
- package/dist/cli/tail.js +49 -118
- package/dist/cli/webhook.js +103 -3
- package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
- package/dist/codex-rc/protocol.js +38 -0
- package/dist/dispatcher/server.js +122 -0
- package/dist/dispatcher.js +52 -83
- package/dist/history.js +49 -27
- package/dist/ipc.js +28 -10
- package/dist/lines.js +54 -0
- package/dist/local-identity.js +80 -0
- package/dist/paths.js +58 -12
- package/dist/trains/protocol.js +99 -0
- package/dist/trains/supervisor.js +210 -0
- package/dist/tunnel.js +39 -1
- package/docs/broker.md +88 -136
- package/docs/monitor.md +88 -10
- package/docs/uri-scheme.md +10 -7
- package/examples/README.md +32 -0
- package/examples/telegram.ts +121 -0
- package/package.json +6 -5
- package/skills/metro/SKILL.md +67 -213
- package/dist/cache.js +0 -69
- package/dist/cli/actions.js +0 -206
- package/dist/cli/skill.js +0 -62
- package/dist/monitor.js +0 -194
- package/dist/registry.js +0 -48
- package/dist/stations/claude.js +0 -45
- package/dist/stations/codex.js +0 -68
- package/dist/stations/discord.js +0 -216
- package/dist/stations/index.js +0 -129
- package/dist/stations/telegram-md.js +0 -34
- package/dist/stations/telegram-upload.js +0 -113
- package/dist/stations/telegram.js +0 -234
- package/dist/stations/webhook.js +0 -103
- package/dist/webhooks.js +0 -41
- package/docs/users.md +0 -226
package/dist/cli/config.js
CHANGED
|
@@ -1,129 +1,119 @@
|
|
|
1
|
-
/**
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
/** `metro setup` / `metro doctor` / `metro update` / `metro setup skill`. */
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
-
import {
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import pkg from '../../package.json' with { type: 'json' };
|
|
6
|
-
import { DiscordStation } from '../stations/discord.js';
|
|
7
|
-
import { TelegramStation } from '../stations/telegram.js';
|
|
8
8
|
import { errMsg } from '../log.js';
|
|
9
|
-
import { CONFIG_ENV_FILE,
|
|
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';
|
|
10
12
|
import { emit, exitErr, isJson, writeJson } from './util.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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')))
|
|
20
47
|
continue;
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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);
|
|
26
56
|
}
|
|
27
|
-
else
|
|
28
|
-
env[key] = value;
|
|
29
|
-
writeDotenv(path, env);
|
|
30
|
-
out.push(path);
|
|
31
57
|
}
|
|
32
|
-
|
|
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 });
|
|
33
61
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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(', ')}` : ''}` }];
|
|
51
83
|
}
|
|
84
|
+
/* ──────────── setup / doctor / update ──────────── */
|
|
52
85
|
export async function cmdSetup(p, f) {
|
|
53
|
-
const [sub
|
|
54
|
-
if (!sub)
|
|
55
|
-
return cmdSetupStatus(f);
|
|
86
|
+
const [sub] = p;
|
|
56
87
|
if (sub === 'skill')
|
|
57
88
|
return cmdSetupSkill(p.slice(1), f);
|
|
58
|
-
if (sub
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (use --no-validate to save anyway)`, 3);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const paths = applyToken(TOKEN_KEYS[sub], trimmed);
|
|
75
|
-
const verified = identity ? ` (verified as ${identity})` : '';
|
|
76
|
-
emit(f, `saved ${TOKEN_KEYS[sub]}${verified} to ${paths.join(', ')}\nrestart metro for the new token to take effect.`, { ok: true, saved: TOKEN_KEYS[sub], paths, verified_as: identity ?? null });
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
if (sub === 'clear') {
|
|
80
|
-
const target = value ?? 'all';
|
|
81
|
-
if (target !== 'all' && target !== 'telegram' && target !== 'discord')
|
|
82
|
-
throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
|
|
83
|
-
const keys = target === 'all' ? Object.values(TOKEN_KEYS) : [TOKEN_KEYS[target]];
|
|
84
|
-
const paths = new Set();
|
|
85
|
-
for (const k of keys)
|
|
86
|
-
for (const path of applyToken(k, null))
|
|
87
|
-
paths.add(path);
|
|
88
|
-
const label = target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target];
|
|
89
|
-
emit(f, `cleared ${label} from ${[...paths].join(', ') || '(no files had it)'}\nrestart metro for changes to take effect.`, { ok: true, cleared: target, paths: [...paths] });
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear, skill)`);
|
|
93
|
-
}
|
|
94
|
-
function tokenSource(key) {
|
|
95
|
-
const val = process.env[key];
|
|
96
|
-
if (!val)
|
|
97
|
-
return '';
|
|
98
|
-
for (const path of [join(process.cwd(), '.env'), CONFIG_ENV_FILE]) {
|
|
99
|
-
if (existsSync(path) && readDotenv(path)[key] === val)
|
|
100
|
-
return path;
|
|
101
|
-
}
|
|
102
|
-
return 'process env';
|
|
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');
|
|
103
102
|
}
|
|
104
103
|
export async function cmdDoctor(_, f) {
|
|
105
104
|
loadMetroEnv();
|
|
106
|
-
const cfg = configuredPlatforms();
|
|
107
105
|
const checks = [];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!cfg[p]) {
|
|
111
|
-
checks.push({ name: p, ok: null, detail: 'not configured' });
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
sources.push(`${p}←${tokenSource(TOKEN_KEYS[p])}`);
|
|
115
|
-
try {
|
|
116
|
-
const me = await stationFor(p).getMe();
|
|
117
|
-
checks.push({ name: p, ok: true, detail: `getMe → ${p === 'telegram' ? '@' : ''}${me.username}` });
|
|
118
|
-
}
|
|
119
|
-
catch (err) {
|
|
120
|
-
checks.push({ name: p, ok: false, detail: errMsg(err) });
|
|
121
|
-
}
|
|
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)` });
|
|
122
108
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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)` });
|
|
127
117
|
const lockFile = join(STATE_DIR, '.tail-lock');
|
|
128
118
|
if (!existsSync(lockFile))
|
|
129
119
|
checks.push({ name: 'dispatcher', ok: null, detail: 'not running' });
|
|
@@ -138,18 +128,22 @@ export async function cmdDoctor(_, f) {
|
|
|
138
128
|
catch {
|
|
139
129
|
checks.push({ name: 'dispatcher', ok: null, detail: 'stale lockfile (auto-reclaims)' });
|
|
140
130
|
}
|
|
141
|
-
checks.push({
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
checks.push({
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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`)' });
|
|
153
147
|
if (isJson(f))
|
|
154
148
|
return writeJson({ checks });
|
|
155
149
|
process.stdout.write('metro doctor\n\n');
|
package/dist/cli/index.js
CHANGED
|
@@ -1,83 +1,65 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
/** Metro CLI
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** Metro CLI: parses argv, dispatches to subcommands. Bun runtime required (uses Bun.spawn for trains). */
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import pkg from '../../package.json' with { type: 'json' };
|
|
4
|
-
import { errMsg } from '../log.js';
|
|
5
|
-
import { listLines } from '../
|
|
6
|
-
import { fmtCapabilities, listStations } from '../stations/index.js';
|
|
7
|
-
import { listUsers } from '../registry.js';
|
|
8
|
-
import { loadMetroEnv } from '../paths.js';
|
|
6
|
+
import { errMsg, log } from '../log.js';
|
|
7
|
+
import { listLines, loadMetroEnv, STATE_DIR } from '../paths.js';
|
|
9
8
|
import { readHistory } from '../history.js';
|
|
10
9
|
import { cmdDoctor, cmdSetup, cmdUpdate } from './config.js';
|
|
11
|
-
import { cmdDownload, cmdEdit, cmdFetch, cmdReact, cmdReply, cmdSend, } from './actions.js';
|
|
12
10
|
import { cmdClaim, cmdClaims, cmdRelease, cmdTail } from './tail.js';
|
|
13
|
-
import { cmdTunnel, cmdWebhook } from './webhook.js';
|
|
11
|
+
import { cmdCall, cmdTrains, cmdTunnel, cmdWebhook } from './webhook.js';
|
|
14
12
|
import { flagOne, isJson, parseArgs, writeJson, } from './util.js';
|
|
15
|
-
|
|
13
|
+
/** True if another live process owns the dispatcher lockfile. Mirrors paths.acquireLock'
|
|
14
|
+
* s detection but as a peek — no claim, no exit. */
|
|
15
|
+
function anotherDispatcherRunning() {
|
|
16
|
+
const lockFile = join(STATE_DIR, '.tail-lock');
|
|
17
|
+
if (!existsSync(lockFile))
|
|
18
|
+
return false;
|
|
19
|
+
const pid = Number(readFileSync(lockFile, 'utf8').trim());
|
|
20
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const USAGE = `metro — event-interception wire. Trains in ~/.metro/trains/ produce events;
|
|
31
|
+
metro multiplexes them onto stdout. Outbound action calls flow back via \`metro call\`.
|
|
16
32
|
|
|
17
33
|
Usage:
|
|
18
34
|
metro Run the dispatcher (emits JSON events on stdout).
|
|
19
|
-
metro setup
|
|
20
|
-
metro setup
|
|
21
|
-
metro setup skill [clear] Install the metro skill into ~/.claude / ~/.codex.
|
|
35
|
+
metro setup Print config status (credentials are owned by trains).
|
|
36
|
+
metro setup skill [clear] Install/remove the metro skill into ~/.claude / ~/.codex.
|
|
22
37
|
metro doctor Health check.
|
|
23
|
-
metro stations List stations + capabilities.
|
|
24
38
|
metro lines List recently-seen conversations.
|
|
25
|
-
metro
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
metro reply <line> <message_id> <text> [--image=… --document=… --voice=… --buttons=…] [--no-claim] [--claim]
|
|
32
|
-
Threaded reply (same flags as send).
|
|
33
|
-
metro edit <line> <message_id> <text> [--buttons=<json>] [--no-claim] [--claim]
|
|
34
|
-
Edit a previously-sent message (text + buttons).
|
|
35
|
-
metro react <line> <message_id> <emoji> [--no-claim] [--claim]
|
|
36
|
-
Set or clear ('') a reaction.
|
|
37
|
-
metro download <line> <message_id> [--out=<dir>]
|
|
38
|
-
Download image attachments to disk.
|
|
39
|
-
metro fetch <line> [--limit=N] Recent-message lookback (Discord only).
|
|
40
|
-
metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
|
|
39
|
+
metro trains [list] List supervised trains (running, pid, fail count).
|
|
40
|
+
metro trains restart <name> Kill + respawn a train (resets backoff).
|
|
41
|
+
metro trains new <name> Scaffold ~/.metro/trains/<name>.ts from the example.
|
|
42
|
+
metro call <train> <action> [args] Forward an action call to a train via its stdin.
|
|
43
|
+
[args] is JSON, '@file', '-' (stdin), or a bare string.
|
|
44
|
+
metro history [--limit=N] [--line=…] [--station=…] [--from=…] [--text=…] [--since=…]
|
|
41
45
|
Read the universal message log (newest first).
|
|
42
46
|
metro tail [--as=<user-uri>] [--follow] [--strict | --unclaimed | --all] [--include-webhooks]
|
|
43
47
|
[--chat=<line>] [--station=…] [--since=<offset|tail>] [--limit=N]
|
|
44
|
-
Subscribe to the event log; claim-aware by default.
|
|
45
|
-
|
|
46
|
-
metro claim <line> [--as=<user-uri>] Take exclusive ownership of a line (so only you receive its events).
|
|
48
|
+
Subscribe to the event log; claim-aware by default.
|
|
49
|
+
metro claim <line> [--as=<user-uri>] Take exclusive ownership of a line.
|
|
47
50
|
metro release <line> Release a line (it returns to broadcast).
|
|
48
51
|
metro claims Print the current claims map.
|
|
49
52
|
metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
|
|
50
53
|
metro webhook list | remove <id> List or remove webhook endpoints.
|
|
51
|
-
metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel
|
|
54
|
+
metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel.
|
|
52
55
|
metro tunnel status Show current tunnel config.
|
|
53
56
|
metro update Upgrade in place.
|
|
54
57
|
metro --version | --help
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
Multi-line
|
|
59
|
+
Trains: place \`<name>.ts\` files in ~/.metro/trains/. See \`@stage-labs/metro/examples\`.
|
|
60
|
+
Lines: metro://<station>/<path>. Multi-line args: pipe on stdin where supported.
|
|
58
61
|
Exit codes: 0 success · 1 usage · 2 config · 3 upstream
|
|
59
62
|
`;
|
|
60
|
-
async function cmdStations(_, f) {
|
|
61
|
-
loadMetroEnv();
|
|
62
|
-
const rows = listStations();
|
|
63
|
-
const usersByStation = {
|
|
64
|
-
claude: listUsers('claude'),
|
|
65
|
-
codex: listUsers('codex'),
|
|
66
|
-
};
|
|
67
|
-
if (isJson(f))
|
|
68
|
-
return writeJson({ stations: rows, users: usersByStation });
|
|
69
|
-
process.stdout.write('metro stations\n\n');
|
|
70
|
-
for (const s of rows) {
|
|
71
|
-
const mark = s.configured === true ? '✓' : s.configured === false ? '✗' : '·';
|
|
72
|
-
process.stdout.write(` ${mark} ${s.name.padEnd(10)} ${fmtCapabilities(s.capabilities)}\n ${s.detail}\n`);
|
|
73
|
-
const seen = usersByStation[s.name] ?? [];
|
|
74
|
-
for (const inst of seen) {
|
|
75
|
-
const sessionsTxt = inst.sessions.length ? ` · sessions: ${inst.sessions.length}` : '';
|
|
76
|
-
process.stdout.write(` seen: ${inst.userId}${sessionsTxt}\n`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
process.stdout.write('\n');
|
|
80
|
-
}
|
|
81
63
|
async function cmdLines(_, f) {
|
|
82
64
|
loadMetroEnv();
|
|
83
65
|
const rows = listLines()
|
|
@@ -112,7 +94,6 @@ async function cmdHistory(_, f) {
|
|
|
112
94
|
const entries = readHistory({
|
|
113
95
|
line: flagOne(f, 'line'),
|
|
114
96
|
station: flagOne(f, 'station'),
|
|
115
|
-
kind: flagOne(f, 'kind'),
|
|
116
97
|
from: flagOne(f, 'from'),
|
|
117
98
|
textContains: flagOne(f, 'text'),
|
|
118
99
|
since: since ? new Date(since) : undefined,
|
|
@@ -124,14 +105,14 @@ async function cmdHistory(_, f) {
|
|
|
124
105
|
process.stdout.write('(no matching history entries)\n');
|
|
125
106
|
return;
|
|
126
107
|
}
|
|
127
|
-
process.stdout.write('time id
|
|
108
|
+
process.stdout.write('time id from → to body\n');
|
|
128
109
|
for (const e of entries.reverse()) {
|
|
129
110
|
const ts = e.ts.slice(11, 19);
|
|
130
111
|
const from = pad(fmtActor(e.from, e.fromName), 24);
|
|
131
112
|
const to = pad(fmtActor(e.to), 24);
|
|
132
|
-
const body = e.text ??
|
|
113
|
+
const body = e.text ?? '';
|
|
133
114
|
const text = body.length > 60 ? body.slice(0, 59) + '…' : body;
|
|
134
|
-
process.stdout.write(`${ts} ${e.id.padEnd(12)} ${
|
|
115
|
+
process.stdout.write(`${ts} ${e.id.padEnd(12)} ${from} → ${to} ${text}\n`);
|
|
135
116
|
}
|
|
136
117
|
}
|
|
137
118
|
/** Compact display: fromName if known; else `station:@<id>` (user) or `station:<id>`. */
|
|
@@ -149,9 +130,8 @@ function fmtActor(uri, name) {
|
|
|
149
130
|
const shortId = (s) => s.length <= 12 ? s : `${s.slice(0, 5)}…${s.slice(-4)}`;
|
|
150
131
|
const pad = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
|
|
151
132
|
const COMMANDS = {
|
|
152
|
-
setup: cmdSetup, doctor: cmdDoctor,
|
|
153
|
-
|
|
154
|
-
download: cmdDownload, fetch: cmdFetch,
|
|
133
|
+
setup: cmdSetup, doctor: cmdDoctor, lines: cmdLines,
|
|
134
|
+
call: cmdCall, trains: cmdTrains,
|
|
155
135
|
webhook: cmdWebhook, tunnel: cmdTunnel,
|
|
156
136
|
history: cmdHistory, tail: cmdTail,
|
|
157
137
|
claim: cmdClaim, release: cmdRelease, claims: cmdClaims,
|
|
@@ -164,6 +144,13 @@ async function main() {
|
|
|
164
144
|
if (cmd === '--help' || cmd === '-h')
|
|
165
145
|
return void process.stdout.write(USAGE);
|
|
166
146
|
if (!cmd) {
|
|
147
|
+
/** Multi-agent: another `metro` already owns the dispatcher → drop into tail mode so
|
|
148
|
+
* a second agent (e.g. Codex while Claude is running) still gets the event stream. */
|
|
149
|
+
if (anotherDispatcherRunning()) {
|
|
150
|
+
log.info({}, 'dispatcher already running; subscribing as tail (--follow --json --since=tail)');
|
|
151
|
+
await cmdTail([], { follow: true, json: true, since: 'tail' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
167
154
|
await import('../dispatcher.js');
|
|
168
155
|
return;
|
|
169
156
|
}
|