@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.14

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.
Files changed (41) hide show
  1. package/README.md +76 -189
  2. package/dist/broker/claims.js +144 -0
  3. package/dist/{broker.js → broker/history-stream.js} +44 -99
  4. package/dist/cli/config.js +115 -121
  5. package/dist/cli/index.js +20 -58
  6. package/dist/cli/tail.js +161 -113
  7. package/dist/cli/webhook.js +103 -3
  8. package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
  9. package/dist/codex-rc/protocol.js +38 -0
  10. package/dist/dispatcher/server.js +130 -0
  11. package/dist/dispatcher.js +51 -82
  12. package/dist/history.js +43 -18
  13. package/dist/ipc.js +28 -10
  14. package/dist/lines.js +54 -0
  15. package/dist/local-identity.js +80 -0
  16. package/dist/paths.js +58 -12
  17. package/dist/trains/protocol.js +99 -0
  18. package/dist/trains/supervisor.js +210 -0
  19. package/dist/tunnel.js +39 -1
  20. package/docs/broker.md +88 -136
  21. package/docs/monitor.md +19 -2
  22. package/docs/uri-scheme.md +10 -7
  23. package/examples/README.md +32 -0
  24. package/examples/telegram.ts +121 -0
  25. package/package.json +6 -5
  26. package/skills/metro/SKILL.md +63 -215
  27. package/dist/cache.js +0 -69
  28. package/dist/cli/actions.js +0 -206
  29. package/dist/cli/skill.js +0 -62
  30. package/dist/monitor.js +0 -194
  31. package/dist/registry.js +0 -48
  32. package/dist/stations/claude.js +0 -45
  33. package/dist/stations/codex.js +0 -68
  34. package/dist/stations/discord.js +0 -216
  35. package/dist/stations/index.js +0 -129
  36. package/dist/stations/telegram-md.js +0 -34
  37. package/dist/stations/telegram-upload.js +0 -113
  38. package/dist/stations/telegram.js +0 -234
  39. package/dist/stations/webhook.js +0 -103
  40. package/dist/webhooks.js +0 -41
  41. package/docs/users.md +0 -226
@@ -1,129 +1,119 @@
1
- /** Setup / doctor / update config-side commands consumed by cli.ts. */
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 { join } from 'node:path';
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, configuredPlatforms, loadMetroEnv, readDotenv, STATE_DIR, writeDotenv, } from '../paths.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';
10
12
  import { emit, exitErr, isJson, writeJson } from './util.js';
11
- import { cmdSetupSkill, skillStatus } from './skill.js';
12
- const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
13
- const stationFor = (p) => p === 'telegram' ? new TelegramStation() : new DiscordStation();
14
- const maskToken = (t) => !t ? '' : t.length <= 8 ? '••••' : `${t.slice(0, 6)}…${t.slice(-2)}`;
15
- /** Apply token across CONFIG_ENV_FILE (always set/cleared) AND cwd/.env (only if it exists). */
16
- function applyToken(key, value) {
17
- const out = [];
18
- for (const path of [CONFIG_ENV_FILE, join(process.cwd(), '.env')]) {
19
- if (path !== CONFIG_ENV_FILE && !existsSync(path))
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 env = readDotenv(path);
22
- if (value === null) {
23
- if (!(key in env))
24
- continue;
25
- delete env[key];
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
- return out;
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
- async function cmdSetupStatus(f) {
35
- loadMetroEnv();
36
- const tg = process.env.TELEGRAM_BOT_TOKEN ?? '', dc = process.env.DISCORD_BOT_TOKEN ?? '';
37
- if (isJson(f))
38
- return writeJson({
39
- version: pkg.version, config_env_file: CONFIG_ENV_FILE,
40
- tokens: { telegram: { set: !!tg, masked: maskToken(tg) }, discord: { set: !!dc, masked: maskToken(dc) } },
41
- });
42
- const cfgState = existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)';
43
- const getStarted = !tg && !dc
44
- ? 'Get started:\n 1. metro setup telegram <token> # https://t.me/BotFather'
45
- + '\n metro setup discord <token> # https://discord.com/developers/applications'
46
- + '\n 2. metro doctor\n 3. metro\n'
47
- : 'Run `metro` to start the dispatcher, or `metro doctor` to verify.\n';
48
- process.stdout.write(`metro ${pkg.version}\n\nconfig: ${CONFIG_ENV_FILE}${cfgState}\n\n`
49
- + ` TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n`
50
- + ` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n${getStarted}`);
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, value] = p;
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 === 'telegram' || sub === 'discord') {
59
- if (!value)
60
- throw new Error(`metro setup ${sub} <token> — token is required`);
61
- const trimmed = value.trim();
62
- let identity;
63
- if (!f['no-validate']) {
64
- process.env[TOKEN_KEYS[sub]] = trimmed;
65
- try {
66
- const me = await stationFor(sub).getMe();
67
- identity = sub === 'telegram' ? `@${me.username}` : me.username;
68
- }
69
- catch (err) {
70
- delete process.env[TOKEN_KEYS[sub]];
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
- const sources = [];
109
- for (const p of ['telegram', 'discord']) {
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
- checks.unshift({
124
- name: 'tokens', ok: cfg.telegram || cfg.discord,
125
- detail: sources.length ? sources.join(', ') : 'no platform configured — run `metro setup telegram|discord <token>`',
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
- name: 'codex-rc', ok: null,
143
- detail: process.env.METRO_CODEX_RC
144
- ? `push enabled → ${process.env.METRO_CODEX_RC}`
145
- : 'not configured (set METRO_CODEX_RC=ws://… to enable Codex push)',
146
- });
147
- const sk = skillStatus();
148
- const installed = Object.entries(sk).filter(([, ok]) => ok).map(([r]) => r);
149
- checks.push({
150
- name: 'skill', ok: installed.length ? true : null,
151
- detail: installed.length ? `installed for ${installed.join(', ')}` : 'not installed (run `metro setup skill`)',
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,46 @@
1
- #!/usr/bin/env node
2
- /** Metro CLI entry: parses argv, dispatches to subcommands, owns action + info commands. */
1
+ #!/usr/bin/env bun
2
+ /** Metro CLI: parses argv, dispatches to subcommands. Bun runtime required (uses Bun.spawn for trains). */
3
3
  import pkg from '../../package.json' with { type: 'json' };
4
4
  import { errMsg } from '../log.js';
5
- import { listLines } from '../cache.js';
6
- import { fmtCapabilities, listStations } from '../stations/index.js';
7
- import { listUsers } from '../registry.js';
8
- import { loadMetroEnv } from '../paths.js';
5
+ import { listLines, loadMetroEnv } from '../paths.js';
9
6
  import { readHistory } from '../history.js';
10
7
  import { cmdDoctor, cmdSetup, cmdUpdate } from './config.js';
11
- import { cmdDownload, cmdEdit, cmdFetch, cmdReact, cmdReply, cmdSend, } from './actions.js';
12
8
  import { cmdClaim, cmdClaims, cmdRelease, cmdTail } from './tail.js';
13
- import { cmdTunnel, cmdWebhook } from './webhook.js';
9
+ import { cmdCall, cmdTrains, cmdTunnel, cmdWebhook } from './webhook.js';
14
10
  import { flagOne, isJson, parseArgs, writeJson, } from './util.js';
15
- const USAGE = `metro — Telegram + Discord stream for your Claude Code / Codex user
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\`.
16
13
 
17
14
  Usage:
18
15
  metro Run the dispatcher (emits JSON events on stdout).
19
- metro setup [telegram|discord <token>] Save token, or show status with no args.
20
- metro setup clear [telegram|discord|all] Remove tokens.
21
- metro setup skill [clear] Install the metro skill into ~/.claude / ~/.codex.
16
+ metro setup Print config status (credentials are owned by trains).
17
+ metro setup skill [clear] Install/remove the metro skill into ~/.claude / ~/.codex.
22
18
  metro doctor Health check.
23
- metro stations List stations + capabilities.
24
19
  metro lines List recently-seen conversations.
25
- metro send <line> <text> [--image=<path>] [--document=<path>]… [--voice=<path>] [--buttons=<json>] [--no-claim] [--claim]
26
- Post a fresh message; repeat --image/--document for multi-file albums.
27
- First outbound on a DM auto-claims; --no-claim or METRO_NO_AUTO_CLAIM=1 opts out;
28
- --claim forces auto-claim even on group/public lines.
29
- Note: send/reply/edit/react read bot tokens from ~/.config/metro/.env and post
30
- directly to the platform — METRO_STATE_DIR isolates claims/history but NOT creds.
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).
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.
40
25
  metro history [--limit=N] [--line=…] [--station=…] [--kind=…] [--from=…] [--text=…] [--since=…]
41
26
  Read the universal message log (newest first).
42
27
  metro tail [--as=<user-uri>] [--follow] [--strict | --unclaimed | --all] [--include-webhooks]
43
28
  [--chat=<line>] [--station=…] [--since=<offset|tail>] [--limit=N]
44
- Subscribe to the event log; claim-aware by default. See docs/broker.md.
45
- Webhooks are hidden in personal modes unless --include-webhooks is set.
46
- metro claim <line> [--as=<user-uri>] Take exclusive ownership of a line (so only you receive its events).
29
+ Subscribe to the event log; claim-aware by default.
30
+ metro claim <line> [--as=<user-uri>] Take exclusive ownership of a line.
47
31
  metro release <line> Release a line (it returns to broadcast).
48
32
  metro claims Print the current claims map.
49
33
  metro webhook add <label> [--secret=…] Register an HTTP receive endpoint (GitHub, Intercom, …).
50
34
  metro webhook list | remove <id> List or remove webhook endpoints.
51
- metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel (run cloudflared tunnel login first).
35
+ metro tunnel setup <name> <hostname> Configure a Cloudflare named tunnel.
52
36
  metro tunnel status Show current tunnel config.
53
37
  metro update Upgrade in place.
54
38
  metro --version | --help
55
39
 
56
- Lines: metro://<station>/<path>. See docs/uri-scheme.md.
57
- Multi-line --text: pipe on stdin in place of the positional arg.
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.
58
42
  Exit codes: 0 success · 1 usage · 2 config · 3 upstream
59
43
  `;
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
44
  async function cmdLines(_, f) {
82
45
  loadMetroEnv();
83
46
  const rows = listLines()
@@ -149,9 +112,8 @@ function fmtActor(uri, name) {
149
112
  const shortId = (s) => s.length <= 12 ? s : `${s.slice(0, 5)}…${s.slice(-4)}`;
150
113
  const pad = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
151
114
  const COMMANDS = {
152
- setup: cmdSetup, doctor: cmdDoctor, stations: cmdStations, lines: cmdLines,
153
- send: cmdSend, reply: cmdReply, edit: cmdEdit, react: cmdReact,
154
- download: cmdDownload, fetch: cmdFetch,
115
+ setup: cmdSetup, doctor: cmdDoctor, lines: cmdLines,
116
+ call: cmdCall, trains: cmdTrains,
155
117
  webhook: cmdWebhook, tunnel: cmdTunnel,
156
118
  history: cmdHistory, tail: cmdTail,
157
119
  claim: cmdClaim, release: cmdRelease, claims: cmdClaims,