@stage-labs/metro 0.1.0-beta.3 → 0.1.0-beta.5

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 CHANGED
@@ -1,15 +1,24 @@
1
1
  # Metro
2
2
 
3
- Run a long-lived daemon that bridges Discord and Telegram to your Codex + Claude Code agents. Each chat thread/topic gets its own agent session with streaming responses and live tool-call status. Both agents run side-by-side — pick per-message with a `with claude` / `with codex` suffix.
3
+ > **Bridge Discord, Telegram, and GitHub to Claude Code + Codex.**
4
4
 
5
- ## Prereqs
5
+ Metro is a small daemon that turns any chat thread, forum topic, or GitHub issue into a live conversation with your local coding agents. Each thread is its own agent session — Claude and Codex run side-by-side, replies stream in real time with tool calls visible inline, and you can stop a turn with one click.
6
6
 
7
- - **Node ≥ 22** (or Bun ≥ 1.3).
8
- - **One or both agent CLIs** installed and authenticated:
9
- - **Claude Code** — run `claude` once interactively to log in. Metro shells out per turn and inherits your auth, plugins, settings.
10
- - **Codex** run `codex` once interactively to log in. Metro spawns `codex app-server` and inherits your auth, MCPs, sandboxing.
11
- - **Discord bot** (optional) with **Message Content Intent** enabled (Developer Portal → Bot → Privileged Gateway Intents).
12
- - **Telegram bot** (optional). In supergroup forums, the bot also needs the **Manage Topics** admin permission so it can auto-create topics on @-mention.
7
+ ```
8
+ [Discord #infra]
9
+
10
+ less @bot we got a 5xx spike from /v1/sync. Look?
11
+ sandboxbot > 🛠 Bash git log --since=24h --oneline
12
+ > 🛠 Read services/sync.ts
13
+
14
+ Three deploys in the last 24h. The 14:02 one swallows a
15
+ timeout into a 500 on line 47 instead of retrying. Want
16
+ me to open a PR?
17
+
18
+ [ ⏹ Stop ]
19
+ ```
20
+
21
+ ---
13
22
 
14
23
  ## Quickstart
15
24
 
@@ -18,97 +27,251 @@ npm install -g @stage-labs/metro@beta # or: bun add -g @stage-labs/metro@beta
18
27
 
19
28
  metro setup discord <token> # https://discord.com/developers/applications
20
29
  metro setup telegram <token> # https://t.me/BotFather
21
-
22
30
  metro doctor # verify
23
- metro # run the orchestrator
31
+ metro # run the dispatcher
24
32
  ```
25
33
 
26
- Metro starts both agents at boot and listens on whichever platforms are configured. Each scope defaults to **Claude** for the first turn; once you've used an agent there, follow-up messages stick with it. Switch per-message by suffixing `with claude` or `with codex` (any casing):
34
+ Requires **Node 22 or Bun 1.3** and at least one of [Claude Code](https://claude.com/claude-code) or [Codex](https://github.com/openai/codex) installed and logged in (run them once interactively first; metro inherits your auth).
35
+
36
+ In **Discord**: DM the bot, or `@<bot>` in any channel. In **Telegram**: DM, or `@<bot>` in a forum's General topic. In **GitHub**: see [Testing GitHub](#testing-github).
37
+
38
+ ---
39
+
40
+ ## Stations
41
+
42
+ Everything in metro is a **station** with declared capabilities:
43
+
44
+ | Station | Kind | Modalities | Features | Config |
45
+ |------------|-------|---------------|-------------------------------------|-------------------------------------------------------------------|
46
+ | `claude` | agent | text + image | stream, tools, cancel, attachments | `claude` CLI on PATH, logged in |
47
+ | `codex` | agent | text + image | stream, tools, cancel, attachments | `codex` CLI on PATH, logged in |
48
+ | `discord` | chat | text + image | stream, edit, attachments | `DISCORD_BOT_TOKEN` + Message Content Intent |
49
+ | `telegram` | chat | text + image | stream, edit, attachments | `TELEGRAM_BOT_TOKEN` + Manage Topics admin (for forums) |
50
+ | `github` | chat | text | edit | `GITHUB_WEBHOOK_SECRET` + `GITHUB_BOT_USERNAME` + `GITHUB_TOKEN` |
51
+
52
+ Run `metro stations` to see the same matrix with live config status (`✓` configured, `✗` not).
53
+
54
+ Each chat platform behaves slightly differently — what's universal is captured by the capabilities; the rest is in [Conversations](#conversations).
55
+
56
+ ---
57
+
58
+ ## Lines
59
+
60
+ Every conversational scope is identified by a **Line** — a URI in the form `metro://<station>/<path>`:
27
61
 
28
62
  ```
29
- @Metro draft a release note
30
- uses Claude (default for a new scope)
63
+ metro://discord/1234567890123456789
64
+ metro://telegram/-1001234567890 # main chat / DM
65
+ metro://telegram/-1001234567890/42 # forum topic 42
66
+ metro://github/bonustrack/metro/issues/123 # GitHub issue
67
+ metro://github/bonustrack/metro/pull/456 # GitHub PR
68
+ ```
69
+
70
+ Lines map 1:1 to agent sessions in `scopes.json`. Anyone can post to a line via [`metro send`](#cli) — daemon optional. Full grammar in [`docs/uri-scheme.md`](docs/uri-scheme.md).
71
+
72
+ ---
73
+
74
+ ## Conversations
75
+
76
+ ### Discord
77
+
78
+ - **DM the bot** — every message is implicit; one line per DM.
79
+ - **`@<bot>` in any guild channel** — metro creates a thread from your message, allocates an agent session, and streams the reply. Follow-ups in the thread route automatically.
80
+ - **Tool calls** — render as `🛠 <tool>` headers plus two fenced code blocks (input → output). Outputs cap at 50 lines / 1500 chars with a `_(N more lines)_` note when truncated. Parallel tool calls are paired by id and don't collide.
81
+ - **Stop button** — every in-flight turn carries an `⏹ Stop` button that aborts the underlying subprocess (Claude via `SIGTERM`, Codex via `turn/interrupt`).
82
+ - **Catchup on restart** — Discord uses a per-line `lastSeenMessageId` watermark; metro REST-fetches anything newer when it comes back up.
83
+
84
+ ### Telegram
85
+
86
+ - **DM the bot** — implicit; one line per chat.
87
+ - **`@<bot>` in a forum supergroup's General topic** — metro creates a new forum topic for the conversation and posts a deep link back in General so it's one tap away. Follow-ups in that topic route automatically.
88
+ - **Inside an existing custom topic** — routes to that topic's line on every message.
89
+ - **Markdown → Telegram HTML** — agent markdown (`**bold**`, `*italic*`, `` `code` ``, fences, `[link](url)`, blockquotes) is converted on the way out. Plain-text fallback if Telegram rejects the HTML.
90
+
91
+ Regular (non-forum) groups are skipped — without a per-thread boundary the routing model breaks down.
92
+
93
+ ### GitHub
94
+
95
+ `@<bot-user>` in an **issue body, issue comment, PR body, or PR comment** allocates a per-issue agent session and the bot replies as a comment on the same issue/PR. Streaming is simulated via `PATCH /issues/comments` edits to the bot's own comment. Each issue/PR holds its own session across follow-ups.
96
+
97
+ Setup:
98
+
99
+ ```
100
+ GITHUB_WEBHOOK_SECRET=<random> # any high-entropy string
101
+ GITHUB_BOT_USERNAME=<github user> # whose @-mentions trigger the bot
102
+ GITHUB_TOKEN=<PAT> # issues:write (+ pull_requests:write for PRs)
103
+ METRO_GITHUB_PORT=4321 # optional, default 4321
104
+ ```
105
+
106
+ In your repo's *Settings → Webhooks*: payload URL `https://<your-public-url>/webhook`, content type `application/json`, the secret matches `GITHUB_WEBHOOK_SECRET`, events: **Issues** + **Issue comments**. For local development tunnel with [smee.io](https://smee.io), [cloudflared](https://github.com/cloudflare/cloudflared), or [ngrok](https://ngrok.com).
107
+
108
+ End-to-end recipe: [Testing GitHub](#testing-github).
109
+
110
+ ---
111
+
112
+ ## Agents
113
+
114
+ Both agents run side-by-side at boot. Each line defaults to **Claude** for the first turn; once you've used an agent in a line, it sticks. Switch per-message with a `with claude` / `with codex` suffix:
115
+
116
+ ```
117
+ @bot draft a release note
118
+ → uses Claude (default for a new line)
31
119
 
32
120
  How would Codex have done this? with codex
33
- → routes this turn to Codex; stays Codex on subsequent turns
121
+ → routes this turn to Codex; the line stays Codex on follow-ups
34
122
  ```
35
123
 
36
- ### Discord
124
+ A line can hold one session per agent — independent histories — so switching back later resumes where that agent left off. If only one agent is installed, metro still starts and asks-for-the-missing-one error inline.
37
125
 
38
- @-mention the bot in any channel:
39
- 1. Metro creates a thread anchored on your message (named after the message).
40
- 2. Spins up an agent session for that thread.
41
- 3. Streams the agent's reply with tool-call status (`Running: <command>`, `Editing 3 files`, `Thinking…`).
126
+ ---
42
127
 
43
- Follow-ups in the thread route automatically — no @-mention needed.
128
+ ## Cross-station relay
44
129
 
45
- ### Telegram
130
+ Agents can post to any line through the CLI:
131
+
132
+ ```bash
133
+ metro send metro://telegram/-1001234567890/42 "patch deployed"
134
+ metro send metro://github/bonustrack/metro/issues/1 "all clear"
135
+ ```
46
136
 
47
- - **DM the bot** every message is implicitly addressed to it; one scope per chat.
48
- - **@-mention the bot in a forum supergroup's General topic** — metro auto-creates a new topic for the conversation (Discord-style "thread from message"). Subsequent messages in that topic route automatically.
49
- - **Inside an existing custom topic** — routes to that topic's scope on every message.
137
+ `metro send` uses the same env tokens as the dispatcher and doesn't require the daemon — useful when an agent in one place needs to relay to another.
50
138
 
51
- Regular (non-forum) groups are not routed — they have no thread boundary.
139
+ ---
52
140
 
53
- ## How it works
141
+ ## Architecture
54
142
 
55
143
  ```
56
- Discord gateway ──┐ ┌─▶ codex app-server (long-lived subprocess, UDS JSON-RPC)
57
- ├─▶ metro orchestrator ─┤
58
- Telegram poller ──┘ └─▶ claude -p ... (per-turn subprocess, stream-json)
59
-
60
- └──── scope map (scopes.json)
144
+ Discord gateway ──┐
145
+ Telegram poller ──┤ ┌─▶ codex station (long-lived `codex app-server`, UDS JSON-RPC)
146
+ GitHub webhook ───┼─▶ metro dispatcher ──────┤
147
+ └─▶ claude station (per-turn `claude -p`, stream-json)
148
+ └─── line → agent-thread map (`scopes.json`)
61
149
  ```
62
150
 
63
- - **One metro = one daemon.** Lockfile at `$METRO_STATE_DIR/.tail-lock` keeps things singleton.
64
- - **Both agents side-by-side.** A scope can have up to one session per agent — independent histories. Routing is per-message: explicit `with claude` / `with codex` suffix, otherwise the scope's last-used agent, otherwise Claude.
65
- - **Streaming.** Replies edit one message every ~1500 ms while deltas stream in (leading-edge first flush for fast initial feedback). Tool calls show as a status line; long replies split past ~1900 chars onto a follow-up message.
66
- - **Queueing.** Messages that arrive while a turn is running are buffered per-scope and answered together in the next reply.
67
- - **Catchup-on-restart.** Discord uses a per-scope `lastSeenMessageId` watermark and REST-fetches anything newer when metro comes back up. Telegram leans on its own update-id queue (persisted offset in `telegram-offset.json`).
151
+ The codebase is built on a small protocol in [`src/stations/types.ts`](src/stations/types.ts):
68
152
 
69
- ## Config
153
+ - **`Station`** — name, capabilities, `start`/`stop` lifecycle.
154
+ - **`AgentStation`** — `createThread()`, `sendTurn(req)` returns an `AsyncIterable<TurnEvent>` of `delta` / `tool-start` / `tool-end`. Cancellation via `AbortSignal`.
155
+ - **`ChatStation<TMeta>`** — `onMessage`/`onStop` event hooks, `send`/`edit` for posting back. Typed meta carries platform extras (`inGuild`, `inForum`, `isPR`, …).
156
+ - **`Line`** — branded URI string. Each station owns its parse/format helpers in [`src/stations/line.ts`](src/stations/line.ts).
157
+
158
+ Adding a backend (Slack, Matrix, SMS, another LLM) = `class XStation implements ChatStation` + a `Line.x(...)` helper. The dispatcher picks it up polymorphically.
159
+
160
+ Behaviors worth knowing:
161
+ - **One daemon per machine.** Lockfile at `$METRO_STATE_DIR/.tail-lock` enforces singleton.
162
+ - **Streaming.** Replies edit one message every ~1500 ms while deltas arrive (leading-edge first flush so feedback feels instant). Long replies split past ~1900 chars onto follow-up messages.
163
+ - **No link previews.** Outgoing messages set `link_preview_options.is_disabled` on Telegram and `SUPPRESS_EMBEDS` on Discord so URLs don't unfurl.
164
+ - **Image attachments.** Discord and Telegram image uploads are forwarded as vision inputs (Anthropic `image/base64` for Claude; `image_url` data URI for Codex). 20 MB cap; non-images surface as `[file: name]` text.
165
+ - **In-flight queueing.** Messages arriving during a turn are buffered per-line and answered as one combined follow-up.
166
+
167
+ ---
168
+
169
+ ## Configuration
70
170
 
71
171
  | Variable | Default | Description |
72
172
  |---|---|---|
73
173
  | `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN` | — | Bot tokens. `metro setup` writes them here. |
174
+ | `GITHUB_WEBHOOK_SECRET`, `GITHUB_BOT_USERNAME`, `GITHUB_TOKEN` | — | Enables GitHub. Token needs `issues:write` (+ `pull_requests:write` for PR comments). Webhook listens on `METRO_GITHUB_PORT` (default `4321`). |
74
175
  | `METRO_CONFIG_DIR` | `~/.config/metro` | Where the global `.env` lives. |
75
- | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, scope cache, codex socket, telegram offset, claude session set. |
176
+ | `METRO_STATE_DIR` | `~/.cache/metro` | Lockfile, line cache, codex socket, telegram offset, claude session set. |
76
177
  | `METRO_LOG_LEVEL` | `info` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
77
178
 
78
- Token precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs to stderr.
179
+ Precedence: process env → `./.env` → `$METRO_CONFIG_DIR/.env`. Logs go to stderr.
180
+
181
+ ---
182
+
183
+ ## CLI
184
+
185
+ ```
186
+ metro Run the dispatcher daemon.
187
+ metro setup [telegram|discord <token>] Save token, or show status.
188
+ metro setup clear [telegram|discord|all] Remove tokens.
189
+ metro doctor Health check.
190
+ metro stations List stations + capabilities.
191
+ metro lines List active conversations (sorted by recency, with names).
192
+ metro send <line> <text> Post to any metro:// line.
193
+ metro update Upgrade in place.
194
+ ```
195
+
196
+ All commands accept `--json` for machine-readable output.
197
+
198
+ **State files** in `$METRO_STATE_DIR`:
199
+ - `scopes.json` — Line → agent-session map
200
+ - `AGENTS.md` — skill doc copied from the package on every dispatcher start; surfaced into each agent's per-turn context so it knows about `metro lines` / `metro send` / `metro stations`
201
+ - `.tail-lock` — dispatcher pid
202
+ - `codex-app-server.sock` — codex Unix socket
203
+ - `telegram-offset.json` — last processed update id
204
+ - `claude-sessions.json` — known Claude session uuids (so restarts use `--resume`)
205
+
206
+ ---
79
207
 
80
- ## Develop locally
208
+ ## Develop
81
209
 
82
210
  ```bash
83
211
  git clone https://github.com/bonustrack/metro && cd metro
84
212
  bun install && bun run build
85
213
  bun link # makes `metro` resolve to this checkout
86
214
  METRO_LOG_LEVEL=debug metro
215
+
216
+ bun run typecheck # ts
217
+ bun run lint # eslint
87
218
  ```
88
219
 
89
- ## Reference
220
+ Source map:
221
+
222
+ - [`src/cli/`](src/cli/) — the `metro` binary entry ([`cli/index.ts`](src/cli/index.ts)) plus subcommand handlers (`lines.ts`, `update.ts`).
223
+ - [`src/dispatcher.ts`](src/dispatcher.ts) — the daemon: wires stations together, routes inbounds, installs `AGENTS.md` into state on each start.
224
+ - [`src/stations/`](src/stations/) — one folder per station (`claude/`, `codex/`, `discord/`, `github/`, `telegram/`) plus the shared `types.ts`, `line.ts`, `listing.ts`, `send.ts`.
225
+ - [`src/helpers/`](src/helpers/) — `streaming.ts`, `turn.ts`, `scope-cache.ts`, `async-queue.ts`.
226
+ - [`docs/uri-scheme.md`](docs/uri-scheme.md) specs the Line format; [`docs/agents.md`](docs/agents.md) is the in-context skill copied to `$METRO_STATE_DIR/AGENTS.md`.
227
+
228
+ CI runs typecheck + lint + build on every PR via [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
229
+
230
+ ---
231
+
232
+ ## Testing GitHub
233
+
234
+ 1. **Pick a GitHub user** the bot acts as (your own account works for testing) and generate a token. Two options:
235
+ - **Fine-grained PAT** at `github.com/settings/tokens?type=beta` — scoped to a test repo with **Issues: Read & write** (+ **Pull requests: Read & write** for PR comments). Works for *your* repos. For org repos, an org admin must enable fine-grained PATs in *Organization settings → Personal access tokens*.
236
+ - **Classic PAT** at `github.com/settings/tokens?type=classic` — pick the `repo` scope. Works for any repo you have access to, no org opt-in needed.
237
+ 2. **Set env vars**:
238
+ ```bash
239
+ export GITHUB_WEBHOOK_SECRET="$(openssl rand -hex 32)"
240
+ export GITHUB_BOT_USERNAME="metrobot" # see solo-testing note below
241
+ export GITHUB_TOKEN="ghp_..."
242
+ ```
243
+ 3. **Tunnel** your local webhook port to the internet:
244
+ ```bash
245
+ npx smee-client --target http://localhost:4321/webhook --url <https://smee.io/your-channel>
246
+ ```
247
+ 4. **Add a webhook** on the test repo (*Settings → Webhooks*): payload URL = the smee channel, content type `application/json`, the same secret, events **Issues** + **Issue comments**.
248
+ 5. **Run metro**: `METRO_LOG_LEVEL=debug metro` — you should see `github station: listening`.
249
+ 6. **Open an issue** with body `@metrobot hello, what's this repo?` — the bot comments back (as the token's owner).
90
250
 
91
- - `metro --help` — command surface
92
- - `metro doctor` — health check (tokens + gateway/poller reachability + orchestrator status)
93
- - State files (`$METRO_STATE_DIR`, defaults to `~/.cache/metro/`):
94
- - `scopes.json` Discord-thread / Telegram-topic agent-session map
95
- - `.tail-lock` orchestrator pid
96
- - `codex-app-server.sock` codex's UDS
97
- - `telegram-offset.json` — last processed update id (used for catchup on restart)
98
- - `claude-sessions.json` — set of started Claude session uuids (so restarts use `--resume` instead of `--session-id`)
251
+ **Solo testing note.** Metro filters out self-mentions (`sender === bot`) to prevent reply loops. If you set `GITHUB_BOT_USERNAME` to your *own* username and then try to `@<yourself>` on an issue, the filter drops it you can't trigger the bot alone. Workaround: use a pseudo-name like `metrobot` (doesn't need to be a real GitHub user — it's just a string match). The bot still replies as the token owner, but the filter passes because `your-username ≠ metrobot`.
252
+
253
+ **Common gotchas:**
254
+ - Webhook 401 secret mismatch between `.env` and the webhook config.
255
+ - Webhook 200 but no reply → token lacks `issues:write`, or `GITHUB_BOT_USERNAME` doesn't appear in the body verbatim (case-sensitive).
256
+ - Bot replies in a loop → the bot's own comment somehow includes `@<bot-username>`; rare with the default prompt, but check `AGENTS.md` if it happens.
257
+
258
+ ---
259
+
260
+ ## Caveats
261
+
262
+ - **No allowlist.** Anyone who can DM/`@`-mention your bot can spawn a session. Run against bots you own.
263
+ - **Per-agent histories are separate.** `with codex` mid-line starts a fresh Codex session; it doesn't see what Claude saw, and vice versa.
264
+ - **Telegram non-forum groups are skipped.** No thread boundary to scope on. DMs and forum topics work normally.
265
+ - **Telegram bot privacy is on by default**, which can block `@`-mentions in groups. Disable via [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy, then kick + re-invite.
266
+ - **GitHub needs a public URL.** Webhooks are HMAC-verified, so a leaked URL only lets attackers spam rejected POSTs — but use a high-entropy `GITHUB_WEBHOOK_SECRET`.
267
+ - **Pre-1.0 line URIs aren't migrated.** Older `discord:ID` / `telegram:CHAT:TOPIC` keys in `scopes.json` are ignored after upgrade.
268
+
269
+ ---
99
270
 
100
271
  ## Uninstall
101
272
 
102
273
  ```bash
103
274
  metro setup clear
104
- rm -rf ~/.cache/metro/
275
+ rm -rf ~/.cache/metro
105
276
  npm uninstall -g @stage-labs/metro
106
277
  ```
107
-
108
- ## Caveats
109
-
110
- - **No allowlist.** Anyone who can DM/@-mention your bot can spawn an agent session. Run against bots you own.
111
- - **Per-agent histories are separate.** Switching with `with codex` mid-scope spins up a fresh Codex session — it has no idea what you discussed with Claude in the same scope. Each agent only sees what was sent to it.
112
- - **One agent missing is OK.** If only `claude` or only `codex` is installed/authenticated, metro still starts; messages asking for the missing one surface an error in chat.
113
- - **Telegram non-forum groups are skipped.** Without a per-topic thread boundary the routing model breaks down. DMs and forum topics (incl. auto-created ones from General) work normally.
114
- - **Telegram bot privacy.** Default Telegram bot privacy is *enabled*, which can block group messages even with @-mentions. Disable in [@BotFather](https://t.me/BotFather) → Bot Settings → Group Privacy → Turn off, then kick + re-invite the bot.
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import pkg from '../../package.json' with { type: 'json' };
5
+ import { cmdLines } from './lines.js';
6
+ import { cmdUpdate } from './update.js';
7
+ import { DiscordStation } from '../stations/discord/index.js';
8
+ import { TelegramStation } from '../stations/telegram/index.js';
9
+ import { fmtCapabilities, listStations } from '../stations/listing.js';
10
+ import { sendToLine } from '../stations/send.js';
11
+ import { errMsg } from '../log.js';
12
+ import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, readDotenv, STATE_DIR, writeDotenv } from '../paths.js';
13
+ const USAGE = `metro — Telegram + Discord bridge for your Claude Code / Codex agent
14
+
15
+ Usage:
16
+ metro Run the dispatcher daemon.
17
+ metro setup [telegram|discord <token>] Save token, or show status with no args.
18
+ metro setup clear [telegram|discord|all] Remove tokens.
19
+ metro doctor Health check.
20
+ metro stations List stations + capabilities.
21
+ metro lines List active conversations (sorted by recency).
22
+ metro send <line> <text> Post a message to a metro:// line.
23
+ metro update Upgrade in place.
24
+ metro --version | --help
25
+ Exit codes: 0 success · 1 usage · 2 config · 3 upstream
26
+ `;
27
+ const exitErr = (msg, code) => Object.assign(new Error(msg), { code });
28
+ const isJson = (f) => f.json === true;
29
+ const emit = (f, human, structured) => void process.stdout.write(isJson(f) ? JSON.stringify(structured) + '\n' : human + '\n');
30
+ const maskToken = (t) => !t ? '' : t.length <= 8 ? '••••' : `${t.slice(0, 6)}…${t.slice(-2)}`;
31
+ const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
32
+ function parseArgs(argv) {
33
+ const positional = [], flags = {};
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (!a.startsWith('--')) {
37
+ positional.push(a);
38
+ continue;
39
+ }
40
+ const eq = a.indexOf('=');
41
+ if (eq !== -1) {
42
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
43
+ continue;
44
+ }
45
+ const key = a.slice(2), next = argv[i + 1];
46
+ if (next !== undefined && !next.startsWith('--')) {
47
+ flags[key] = next;
48
+ i++;
49
+ }
50
+ else
51
+ flags[key] = true;
52
+ }
53
+ return { positional, flags };
54
+ }
55
+ /** Apply a token across CONFIG_ENV_FILE (always set/cleared) AND cwd/.env (only if it exists). Returns paths touched. */
56
+ function applyTokenToAllEnvs(key, value) {
57
+ const out = [];
58
+ for (const path of [CONFIG_ENV_FILE, join(process.cwd(), '.env')]) {
59
+ if (path !== CONFIG_ENV_FILE && !existsSync(path))
60
+ continue;
61
+ const env = readDotenv(path);
62
+ if (value === null) {
63
+ if (!(key in env))
64
+ continue;
65
+ delete env[key];
66
+ }
67
+ else
68
+ env[key] = value;
69
+ writeDotenv(path, env);
70
+ out.push(path);
71
+ }
72
+ return out;
73
+ }
74
+ async function cmdSetup(positional, flags) {
75
+ const [sub, value] = positional;
76
+ if (!sub)
77
+ return cmdSetupStatus(flags);
78
+ if (sub === 'telegram' || sub === 'discord') {
79
+ if (!value)
80
+ throw new Error(`metro setup ${sub} <token> — token is required`);
81
+ const trimmed = value.trim();
82
+ let identity;
83
+ if (!flags['no-validate']) {
84
+ process.env[TOKEN_KEYS[sub]] = trimmed;
85
+ try {
86
+ identity = sub === 'telegram' ? `@${(await new TelegramStation().getMe()).username}` : (await new DiscordStation().getMe()).username;
87
+ }
88
+ catch (err) {
89
+ delete process.env[TOKEN_KEYS[sub]];
90
+ throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (use --no-validate to save anyway)`, 3);
91
+ }
92
+ }
93
+ const paths = applyTokenToAllEnvs(TOKEN_KEYS[sub], trimmed);
94
+ emit(flags, `saved ${TOKEN_KEYS[sub]}${identity ? ` (verified as ${identity})` : ''} to ${paths.join(', ')}\nrestart metro for the new token to take effect.`, { ok: true, saved: TOKEN_KEYS[sub], paths, verified_as: identity ?? null });
95
+ return;
96
+ }
97
+ if (sub === 'clear') {
98
+ const target = value ?? 'all';
99
+ if (target !== 'all' && target !== 'telegram' && target !== 'discord')
100
+ throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
101
+ const keys = target === 'all' ? ['TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN'] : [TOKEN_KEYS[target]];
102
+ const paths = new Set();
103
+ for (const k of keys)
104
+ for (const p of applyTokenToAllEnvs(k, null))
105
+ paths.add(p);
106
+ const label = target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target];
107
+ return void emit(flags, `cleared ${label} from ${[...paths].join(', ') || '(no files had it)'}\nrestart metro for changes to take effect.`, { ok: true, cleared: target, paths: [...paths] });
108
+ }
109
+ throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear)`);
110
+ }
111
+ async function cmdSetupStatus(flags) {
112
+ loadMetroEnv();
113
+ const tg = process.env.TELEGRAM_BOT_TOKEN ?? '', dc = process.env.DISCORD_BOT_TOKEN ?? '';
114
+ if (isJson(flags))
115
+ return void process.stdout.write(JSON.stringify({ version: pkg.version, config_env_file: CONFIG_ENV_FILE,
116
+ tokens: { telegram: { set: !!tg, masked: maskToken(tg) }, discord: { set: !!dc, masked: maskToken(dc) } } }) + '\n');
117
+ const cfgState = existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)';
118
+ process.stdout.write(`metro ${pkg.version}\n\nconfig: ${CONFIG_ENV_FILE}${cfgState}\n\n TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n${!tg && !dc
119
+ ? 'Get started:\n 1. metro setup telegram <token> # https://t.me/BotFather\n metro setup discord <token> # https://discord.com/developers/applications\n 2. metro doctor\n 3. metro\n'
120
+ : 'Run `metro` to start the dispatcher, or `metro doctor` to verify.\n'}`);
121
+ }
122
+ /** Find which file (or process env) supplied the currently-loaded token, so doctor can name the real source. */
123
+ function tokenSource(key) {
124
+ const val = process.env[key];
125
+ if (!val)
126
+ return '';
127
+ for (const path of [join(process.cwd(), '.env'), CONFIG_ENV_FILE])
128
+ if (existsSync(path) && readDotenv(path)[key] === val)
129
+ return path;
130
+ return 'process env';
131
+ }
132
+ async function cmdDoctor(flags) {
133
+ loadMetroEnv();
134
+ const cfg = configuredPlatforms();
135
+ const sources = [['telegram', 'TELEGRAM_BOT_TOKEN'], ['discord', 'DISCORD_BOT_TOKEN']].filter(([p]) => cfg[p]).map(([p, k]) => `${p}←${tokenSource(k)}`).join(', ');
136
+ const checks = [{ name: 'tokens', ok: cfg.telegram || cfg.discord,
137
+ detail: cfg.telegram || cfg.discord ? sources : 'no platform configured — run `metro setup telegram|discord <token>`',
138
+ }];
139
+ const getMeFns = { telegram: () => new TelegramStation().getMe(), discord: () => new DiscordStation().getMe() };
140
+ for (const p of ['telegram', 'discord']) {
141
+ if (!cfg[p]) {
142
+ checks.push({ name: p, ok: null, detail: 'not configured' });
143
+ continue;
144
+ }
145
+ try {
146
+ const me = await getMeFns[p]();
147
+ checks.push({ name: p, ok: true, detail: `getMe → ${p === 'telegram' ? '@' : ''}${me.username}` });
148
+ }
149
+ catch (err) {
150
+ checks.push({ name: p, ok: false, detail: errMsg(err) });
151
+ }
152
+ }
153
+ checks.push(dispatcherCheck());
154
+ if (isJson(flags))
155
+ process.stdout.write(JSON.stringify({ checks }) + '\n');
156
+ else {
157
+ process.stdout.write('metro doctor\n\n');
158
+ for (const c of checks)
159
+ process.stdout.write(` ${c.ok === true ? '✓' : c.ok === false ? '✗' : '–'} ${c.name.padEnd(15)} ${c.detail}\n`);
160
+ process.stdout.write('\n');
161
+ }
162
+ if (checks.some(c => c.ok === false))
163
+ throw exitErr('one or more checks failed', 3);
164
+ }
165
+ function dispatcherCheck() {
166
+ const lockFile = join(STATE_DIR, '.tail-lock');
167
+ if (!existsSync(lockFile))
168
+ return { name: 'dispatcher', ok: null, detail: 'not running' };
169
+ try {
170
+ const pid = Number(readFileSync(lockFile, 'utf8').trim());
171
+ if (!Number.isInteger(pid) || pid <= 0)
172
+ throw new Error('invalid pid');
173
+ process.kill(pid, 0);
174
+ return { name: 'dispatcher', ok: true, detail: `running (pid ${pid})` };
175
+ }
176
+ catch {
177
+ return { name: 'dispatcher', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' };
178
+ }
179
+ }
180
+ async function cmdSend(positional, flags) {
181
+ const [to, ...rest] = positional;
182
+ if (!to || !rest.length)
183
+ throw exitErr('usage: metro send <line> <text>', 1);
184
+ loadMetroEnv();
185
+ const { line, messageId } = await sendToLine(to, rest.join(' '));
186
+ emit(flags, `sent ${messageId} to ${line}`, { ok: true, line, messageId });
187
+ }
188
+ async function cmdStations(flags) {
189
+ loadMetroEnv();
190
+ const rows = listStations();
191
+ if (isJson(flags))
192
+ return void process.stdout.write(JSON.stringify({ stations: rows }) + '\n');
193
+ process.stdout.write('metro stations\n\n');
194
+ for (const s of rows) {
195
+ const mark = s.configured === true ? '✓' : s.configured === false ? '✗' : '·';
196
+ process.stdout.write(` ${mark} ${s.name.padEnd(10)} ${s.kind.padEnd(6)} ${fmtCapabilities(s.capabilities)}\n ${s.detail}\n`);
197
+ }
198
+ process.stdout.write('\n');
199
+ }
200
+ const COMMANDS = {
201
+ setup: cmdSetup,
202
+ doctor: (_, f) => cmdDoctor(f),
203
+ stations: (_, f) => cmdStations(f),
204
+ lines: (_, f) => cmdLines(isJson(f)),
205
+ send: cmdSend,
206
+ update: (_, f) => cmdUpdate(isJson(f)),
207
+ };
208
+ async function main() {
209
+ const cmd = process.argv[2];
210
+ if (cmd === '--version' || cmd === '-v')
211
+ return void process.stdout.write(`${pkg.version}\n`);
212
+ if (cmd === '--help' || cmd === '-h')
213
+ return void process.stdout.write(USAGE);
214
+ if (!cmd) {
215
+ await import('../dispatcher.js');
216
+ return;
217
+ }
218
+ const handler = COMMANDS[cmd];
219
+ if (!handler) {
220
+ process.stderr.write(`unknown command '${cmd}'\n\n${USAGE}`);
221
+ process.exit(1);
222
+ }
223
+ const { positional, flags } = parseArgs(process.argv.slice(3));
224
+ try {
225
+ await handler(positional, flags);
226
+ }
227
+ catch (err) {
228
+ const code = err.code;
229
+ if (isJson(flags))
230
+ process.stdout.write(JSON.stringify({ ok: false, error: errMsg(err), code: code ?? 1 }) + '\n');
231
+ else
232
+ process.stderr.write(`error: ${errMsg(err)}\n`);
233
+ process.exit(typeof code === 'number' ? code : 1);
234
+ }
235
+ }
236
+ await main();
@@ -0,0 +1,40 @@
1
+ /** `metro lines` — list active lines from scopes.json, sorted by recency. */
2
+ import { listLines } from '../helpers/scope-cache.js';
3
+ import { loadMetroEnv } from '../paths.js';
4
+ export async function cmdLines(json) {
5
+ loadMetroEnv();
6
+ const rows = listLines()
7
+ /** Drop pre-URI legacy keys (e.g. `discord:ID`) — they're not routable and just clutter the listing. */
8
+ .filter(({ line }) => line.startsWith('metro://'))
9
+ .map(({ line, entry }) => ({
10
+ line, name: entry.name ?? null,
11
+ lastSeenAt: entry.lastSeenAt ?? null,
12
+ lastAgent: entry.lastAgent ?? null,
13
+ agents: Object.keys(entry.agents ?? {}),
14
+ }))
15
+ .sort((a, b) => (b.lastSeenAt ?? '').localeCompare(a.lastSeenAt ?? ''));
16
+ if (json)
17
+ return void process.stdout.write(JSON.stringify({ lines: rows }) + '\n');
18
+ if (!rows.length)
19
+ return void process.stdout.write('metro lines\n\n (none yet — start a conversation to populate)\n\n');
20
+ const widest = Math.max(...rows.map(r => r.line.length));
21
+ process.stdout.write('metro lines\n\n');
22
+ for (const r of rows) {
23
+ const when = r.lastSeenAt ? humanAgo(r.lastSeenAt) : '—';
24
+ const agent = r.lastAgent ?? r.agents.join('+') ?? '—';
25
+ const tag = r.name ? ` ${truncate(r.name, 40)}` : '';
26
+ process.stdout.write(` ${when.padEnd(10)} ${agent.padEnd(8)} ${r.line.padEnd(widest)}${tag}\n`);
27
+ }
28
+ process.stdout.write('\n');
29
+ }
30
+ const humanAgo = (iso) => {
31
+ const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
32
+ if (sec < 60)
33
+ return `${sec}s ago`;
34
+ if (sec < 3600)
35
+ return `${Math.floor(sec / 60)}m ago`;
36
+ if (sec < 86400)
37
+ return `${Math.floor(sec / 3600)}h ago`;
38
+ return `${Math.floor(sec / 86400)}d ago`;
39
+ };
40
+ const truncate = (s, n) => s.length <= n ? s : s.slice(0, n - 1) + '…';
@@ -0,0 +1,27 @@
1
+ /** `metro update` — npm registry lookup + in-place global install. */
2
+ import { spawn } from 'node:child_process';
3
+ import pkg from '../../package.json' with { type: 'json' };
4
+ const emit = (json, human, structured) => {
5
+ process.stdout.write(json ? JSON.stringify(structured) + '\n' : human + '\n');
6
+ };
7
+ export async function cmdUpdate(json) {
8
+ const tag = pkg.version.includes('-') ? 'beta' : 'latest';
9
+ const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
10
+ if (!res.ok)
11
+ throw new Error(`npm registry: ${res.status}`);
12
+ const latest = (await res.json())['dist-tags']?.[tag];
13
+ if (!latest)
14
+ throw new Error(`no '${tag}' dist-tag for @stage-labs/metro`);
15
+ if (latest === pkg.version)
16
+ return emit(json, `already on ${pkg.version} (latest ${tag})`, { ok: true, current: pkg.version, latest, upgraded: false });
17
+ const argv1 = process.argv[1] ?? '', spec = `@stage-labs/metro@${tag}`;
18
+ const argv = argv1.includes('/.bun/') || argv1.includes('\\bun\\') ? ['bun', 'add', '-g', spec]
19
+ : argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
20
+ : ['npm', 'install', '-g', spec];
21
+ emit(json, `metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}`, { ok: true, current: pkg.version, latest, command: argv.join(' ') });
22
+ await new Promise((resolve, reject) => {
23
+ const child = spawn(argv[0], argv.slice(1), { stdio: json ? 'ignore' : 'inherit' });
24
+ child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`)));
25
+ child.on('error', reject);
26
+ });
27
+ }