@stage-labs/metro 0.1.0-beta.2 → 0.1.0-beta.4
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 +70 -31
- package/dist/agents/claude.js +207 -0
- package/dist/agents/codex.js +207 -0
- package/dist/agents/types.js +2 -0
- package/dist/channels/discord.js +36 -80
- package/dist/channels/telegram.js +136 -84
- package/dist/cli.js +68 -420
- package/dist/helpers/scope-cache.js +65 -0
- package/dist/helpers/streaming.js +209 -0
- package/dist/helpers/telegram-format.js +39 -0
- package/dist/helpers/turn.js +40 -0
- package/dist/log.js +1 -3
- package/dist/orchestrator.js +208 -0
- package/dist/paths.js +52 -29
- package/package.json +2 -3
- package/dist/lib/address.js +0 -21
- package/dist/lib/codex-rc.js +0 -274
- package/dist/lib/dotenv.js +0 -31
- package/dist/tail.js +0 -161
- package/skills/metro/SKILL.md +0 -122
package/dist/cli.js
CHANGED
|
@@ -1,73 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
6
5
|
import pkg from '../package.json' with { type: 'json' };
|
|
7
6
|
import * as discord from './channels/discord.js';
|
|
8
7
|
import * as telegram from './channels/telegram.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
import { errMsg, log } from './log.js';
|
|
13
|
-
import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, STATE_DIR, skillDir, } from './paths.js';
|
|
14
|
-
// ---------------- USAGE ----------------------------------------------------
|
|
15
|
-
const USAGE = `metro — Telegram + Discord bridge for your agent
|
|
8
|
+
import { errMsg } from './log.js';
|
|
9
|
+
import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, readDotenv, STATE_DIR, writeDotenv } from './paths.js';
|
|
10
|
+
const USAGE = `metro — Telegram + Discord bridge for your Claude Code / Codex agent
|
|
16
11
|
|
|
17
12
|
Usage:
|
|
18
|
-
metro
|
|
19
|
-
metro setup [
|
|
20
|
-
metro doctor Health check across tokens, gateways, and skills.
|
|
21
|
-
metro reply --to=<addr> [--text=<t>] Quote-reply, threading under the original. Clears 👀.
|
|
22
|
-
metro react --to=<addr> --emoji=<e> Set or clear ('') a reaction.
|
|
23
|
-
metro edit --to=<addr> [--text=<t>] Edit a message the bot previously sent.
|
|
24
|
-
metro send --to=<addr> [--text=<t>] Send a proactive message (no reply context).
|
|
25
|
-
<addr> is channel-only: <platform>:<chat_id> (no /message_id).
|
|
26
|
-
metro download --to=<addr> [--out=<dir>] Download image attachments to disk.
|
|
27
|
-
metro fetch --to=<addr> [--limit=N] Recent-message lookback (Discord only).
|
|
28
|
-
metro update Upgrade in place (npm/bun/pnpm auto-detected).
|
|
29
|
-
|
|
30
|
-
setup verbs:
|
|
31
|
-
metro setup Status: tokens, skills, what's next.
|
|
32
|
-
metro setup telegram <token> Save TELEGRAM_BOT_TOKEN (validated via getMe; --no-validate skips).
|
|
33
|
-
metro setup discord <token> Save DISCORD_BOT_TOKEN (validated via getMe; --no-validate skips).
|
|
13
|
+
metro Run the orchestrator daemon.
|
|
14
|
+
metro setup [telegram|discord <token>] Save token, or show status with no args.
|
|
34
15
|
metro setup clear [telegram|discord|all] Remove tokens.
|
|
35
|
-
metro
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
telegram:<chat_id>/<message_id> e.g. telegram:-100123456789/4567
|
|
39
|
-
discord:<channel_id>/<message_id> e.g. discord:1234567890/9876543210
|
|
40
|
-
discord:<channel_id> fetch only (no message id)
|
|
41
|
-
|
|
42
|
-
Common flags:
|
|
43
|
-
--json machine-parseable output (single JSON line/array)
|
|
44
|
-
--version, -v print the metro version
|
|
45
|
-
--help, -h print this help
|
|
46
|
-
|
|
47
|
-
reply / edit Telegram extras:
|
|
48
|
-
--parse-mode=HTML|MarkdownV2 --no-link-preview --buttons-json='[[{"text":"…","url":"…"}]]'
|
|
49
|
-
|
|
50
|
-
Exit codes:
|
|
51
|
-
0 success
|
|
52
|
-
1 usage error (bad flags, unknown subcommand)
|
|
53
|
-
2 configuration error (no tokens — run \`metro setup\`)
|
|
54
|
-
3 upstream error (rate limit, auth, network — retry once, then surface)
|
|
55
|
-
|
|
56
|
-
Codex push (opt-in):
|
|
57
|
-
Set METRO_CODEX_RC to the codex app-server URL — metro will push each
|
|
58
|
-
inbound into the agent's history via JSON-RPC \`turn/start\`, the Codex
|
|
59
|
-
equivalent of Claude Code's Monitor.
|
|
60
|
-
|
|
61
|
-
Three terminals share the same URL (codex 0.130's TUI --remote only
|
|
62
|
-
accepts ws://):
|
|
16
|
+
metro doctor Health check.
|
|
17
|
+
metro update Upgrade in place.
|
|
18
|
+
metro --version | --help
|
|
63
19
|
|
|
64
|
-
|
|
65
|
-
METRO_CODEX_RC=ws://127.0.0.1:8421 metro # bridge
|
|
66
|
-
codex --remote ws://127.0.0.1:8421 # TUI
|
|
20
|
+
Exit codes: 0 success · 1 usage · 2 config · 3 upstream
|
|
67
21
|
`;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
22
|
+
const exitErr = (msg, code) => Object.assign(new Error(msg), { code });
|
|
23
|
+
const isJson = (f) => f.json === true;
|
|
24
|
+
const emit = (f, human, structured) => {
|
|
25
|
+
process.stdout.write(isJson(f) ? JSON.stringify(structured) + '\n' : human + '\n');
|
|
26
|
+
};
|
|
27
|
+
const maskToken = (t) => !t ? '' : t.length <= 8 ? '••••' : `${t.slice(0, 6)}…${t.slice(-2)}`;
|
|
28
|
+
const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
|
|
71
29
|
function parseArgs(argv) {
|
|
72
30
|
const positional = [];
|
|
73
31
|
const flags = {};
|
|
@@ -93,92 +51,6 @@ function parseArgs(argv) {
|
|
|
93
51
|
}
|
|
94
52
|
return { positional, flags };
|
|
95
53
|
}
|
|
96
|
-
const isJson = (flags) => flags.json === true;
|
|
97
|
-
function emitResult(flags, human, structured) {
|
|
98
|
-
process.stdout.write(isJson(flags) ? JSON.stringify(structured) + '\n' : human + '\n');
|
|
99
|
-
}
|
|
100
|
-
function resolveAddr(flags, requireMessage) {
|
|
101
|
-
const addr = parseAddress(String(flags.to), requireMessage);
|
|
102
|
-
if (!configuredPlatforms()[addr.platform]) {
|
|
103
|
-
throw exitErr(`platform '${addr.platform}' is not configured (missing token)`, 2);
|
|
104
|
-
}
|
|
105
|
-
return addr;
|
|
106
|
-
}
|
|
107
|
-
function tgMessageId(addr) {
|
|
108
|
-
const n = Number(addr.messageId);
|
|
109
|
-
if (!Number.isInteger(n))
|
|
110
|
-
throw new Error(`telegram message_id must be an integer: ${addr.messageId}`);
|
|
111
|
-
return n;
|
|
112
|
-
}
|
|
113
|
-
// Tell tail.ts to stop refreshing the typing indicator (the agent has replied).
|
|
114
|
-
function signalReplyComplete(platform, chat) {
|
|
115
|
-
const dir = join(STATE_DIR, '.typing-stop');
|
|
116
|
-
try {
|
|
117
|
-
mkdirSync(dir, { recursive: true });
|
|
118
|
-
writeFileSync(join(dir, `${platform}_${chat}`), '');
|
|
119
|
-
}
|
|
120
|
-
catch (err) {
|
|
121
|
-
log.warn({ err: errMsg(err) }, 'typing stop-signal write failed');
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
async function clearReaction(addr) {
|
|
125
|
-
if (addr.platform === 'telegram') {
|
|
126
|
-
await tg('setMessageReaction', { chat_id: addr.chat, message_id: tgMessageId(addr), reaction: [] });
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
await discord.setReaction(addr.chat, addr.messageId, '');
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
async function readStdinText() {
|
|
133
|
-
if (process.stdin.isTTY)
|
|
134
|
-
return '';
|
|
135
|
-
const chunks = [];
|
|
136
|
-
for await (const c of process.stdin)
|
|
137
|
-
chunks.push(c);
|
|
138
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
139
|
-
}
|
|
140
|
-
async function resolveText(flags) {
|
|
141
|
-
if (typeof flags.text === 'string')
|
|
142
|
-
return flags.text;
|
|
143
|
-
const stdin = (await readStdinText()).replace(/\n$/, '');
|
|
144
|
-
if (!stdin)
|
|
145
|
-
throw new Error('--text is required (or pipe text on stdin)');
|
|
146
|
-
return stdin;
|
|
147
|
-
}
|
|
148
|
-
function readTelegramOpts(flags) {
|
|
149
|
-
const opts = {};
|
|
150
|
-
if (flags['parse-mode']) {
|
|
151
|
-
const v = String(flags['parse-mode']);
|
|
152
|
-
if (v !== 'HTML' && v !== 'MarkdownV2')
|
|
153
|
-
throw new Error("--parse-mode must be 'HTML' or 'MarkdownV2'");
|
|
154
|
-
opts.parseMode = v;
|
|
155
|
-
}
|
|
156
|
-
if (flags['no-link-preview'])
|
|
157
|
-
opts.disableLinkPreview = true;
|
|
158
|
-
if (flags['buttons-json'])
|
|
159
|
-
opts.buttons = JSON.parse(String(flags['buttons-json']));
|
|
160
|
-
return opts;
|
|
161
|
-
}
|
|
162
|
-
function maskToken(token) {
|
|
163
|
-
if (!token)
|
|
164
|
-
return '';
|
|
165
|
-
if (token.length <= 8)
|
|
166
|
-
return '••••';
|
|
167
|
-
return `${token.slice(0, 6)}…${token.slice(-2)}`;
|
|
168
|
-
}
|
|
169
|
-
const EXT_FROM_MIME = {
|
|
170
|
-
'image/png': 'png',
|
|
171
|
-
'image/jpeg': 'jpg',
|
|
172
|
-
'image/gif': 'gif',
|
|
173
|
-
'image/webp': 'webp',
|
|
174
|
-
};
|
|
175
|
-
// Path to the SKILL.md bundled with the npm package. dist/cli.js → ../skills/metro/SKILL.md.
|
|
176
|
-
const BUNDLED_SKILL = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'metro', 'SKILL.md');
|
|
177
|
-
const SKILL_RUNTIMES = ['claude-code', 'codex'];
|
|
178
|
-
const SKILL_SCOPES = ['user', 'project'];
|
|
179
|
-
const skillFile = (runtime, scope) => join(skillDir(runtime, scope), 'SKILL.md');
|
|
180
|
-
// ---------------- setup ----------------------------------------------------
|
|
181
|
-
const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
|
|
182
54
|
async function cmdSetup(positional, flags) {
|
|
183
55
|
const [sub, value] = positional;
|
|
184
56
|
if (!sub)
|
|
@@ -189,26 +61,20 @@ async function cmdSetup(positional, flags) {
|
|
|
189
61
|
const trimmed = value.trim();
|
|
190
62
|
let identity;
|
|
191
63
|
if (!flags['no-validate']) {
|
|
192
|
-
// Validate against the platform before persisting — catches typos /
|
|
193
|
-
// wrong-token-from-portal / rotated tokens at the earliest moment.
|
|
194
64
|
process.env[TOKEN_KEYS[sub]] = trimmed;
|
|
195
65
|
try {
|
|
196
|
-
identity = sub === 'telegram'
|
|
197
|
-
? `@${(await telegram.getMe()).username}`
|
|
198
|
-
: (await discord.getMe()).username;
|
|
66
|
+
identity = sub === 'telegram' ? `@${(await telegram.getMe()).username}` : (await discord.getMe()).username;
|
|
199
67
|
}
|
|
200
68
|
catch (err) {
|
|
201
69
|
delete process.env[TOKEN_KEYS[sub]];
|
|
202
|
-
throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (
|
|
70
|
+
throw exitErr(`token rejected by ${sub}: ${errMsg(err)} (use --no-validate to save anyway)`, 3);
|
|
203
71
|
}
|
|
204
72
|
}
|
|
205
73
|
const env = readDotenv(CONFIG_ENV_FILE);
|
|
206
74
|
env[TOKEN_KEYS[sub]] = trimmed;
|
|
207
75
|
writeDotenv(CONFIG_ENV_FILE, env);
|
|
208
|
-
const human = identity
|
|
209
|
-
|
|
210
|
-
: `saved ${TOKEN_KEYS[sub]} to ${CONFIG_ENV_FILE} (chmod 0600)`;
|
|
211
|
-
emitResult(flags, human, { ok: true, saved: TOKEN_KEYS[sub], path: CONFIG_ENV_FILE, verified_as: identity ?? null });
|
|
76
|
+
const human = `saved ${TOKEN_KEYS[sub]}${identity ? ` (verified as ${identity})` : ''} to ${CONFIG_ENV_FILE} (chmod 0600)`;
|
|
77
|
+
emit(flags, human, { ok: true, saved: TOKEN_KEYS[sub], verified_as: identity ?? null });
|
|
212
78
|
return;
|
|
213
79
|
}
|
|
214
80
|
if (sub === 'clear') {
|
|
@@ -218,334 +84,120 @@ async function cmdSetup(positional, flags) {
|
|
|
218
84
|
delete env.TELEGRAM_BOT_TOKEN;
|
|
219
85
|
delete env.DISCORD_BOT_TOKEN;
|
|
220
86
|
}
|
|
221
|
-
else if (target === 'telegram' || target === 'discord')
|
|
87
|
+
else if (target === 'telegram' || target === 'discord')
|
|
222
88
|
delete env[TOKEN_KEYS[target]];
|
|
223
|
-
|
|
224
|
-
else {
|
|
89
|
+
else
|
|
225
90
|
throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
|
|
226
|
-
}
|
|
227
91
|
writeDotenv(CONFIG_ENV_FILE, env);
|
|
228
|
-
|
|
229
|
-
emitResult(flags, human, { ok: true, cleared: target, path: CONFIG_ENV_FILE });
|
|
92
|
+
emit(flags, `cleared ${target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target]}`, { ok: true, cleared: target });
|
|
230
93
|
return;
|
|
231
94
|
}
|
|
232
|
-
|
|
233
|
-
return cmdSetupSkill(flags);
|
|
234
|
-
throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear, skill)`);
|
|
235
|
-
}
|
|
236
|
-
function readSkillState() {
|
|
237
|
-
return Object.fromEntries(SKILL_RUNTIMES.map(r => [r, Object.fromEntries(SKILL_SCOPES.map(s => [s, existsSync(skillFile(r, s))]))]));
|
|
95
|
+
throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear)`);
|
|
238
96
|
}
|
|
239
97
|
async function cmdSetupStatus(flags) {
|
|
240
98
|
loadMetroEnv();
|
|
241
99
|
const tg = process.env.TELEGRAM_BOT_TOKEN ?? '';
|
|
242
100
|
const dc = process.env.DISCORD_BOT_TOKEN ?? '';
|
|
243
|
-
const skills = readSkillState();
|
|
244
|
-
const anySkill = SKILL_RUNTIMES.some(r => SKILL_SCOPES.some(s => skills[r][s]));
|
|
245
101
|
if (isJson(flags)) {
|
|
246
102
|
process.stdout.write(JSON.stringify({
|
|
247
103
|
version: pkg.version,
|
|
248
104
|
config_env_file: CONFIG_ENV_FILE,
|
|
249
|
-
tokens: {
|
|
250
|
-
telegram: { set: !!tg, masked: maskToken(tg) },
|
|
251
|
-
discord: { set: !!dc, masked: maskToken(dc) },
|
|
252
|
-
},
|
|
253
|
-
skills,
|
|
105
|
+
tokens: { telegram: { set: !!tg, masked: maskToken(tg) }, discord: { set: !!dc, masked: maskToken(dc) } },
|
|
254
106
|
}) + '\n');
|
|
255
107
|
return;
|
|
256
108
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
return set.length ? `installed (${set.join(' + ')})` : 'not installed';
|
|
260
|
-
};
|
|
261
|
-
process.stdout.write(`metro ${pkg.version}\n\n` +
|
|
262
|
-
`config: ${CONFIG_ENV_FILE}${existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)'}\n\n` +
|
|
109
|
+
const cfgState = existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)';
|
|
110
|
+
process.stdout.write(`metro ${pkg.version}\n\nconfig: ${CONFIG_ENV_FILE}${cfgState}\n\n` +
|
|
263
111
|
` TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n` +
|
|
264
|
-
` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n`
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
`
|
|
268
|
-
if (!tg && !dc) {
|
|
269
|
-
process.stdout.write('Get started:\n' +
|
|
270
|
-
' 1. metro setup telegram <token> # https://t.me/BotFather\n' +
|
|
271
|
-
' metro setup discord <token> # https://discord.com/developers/applications\n' +
|
|
272
|
-
' 2. metro setup skill # auto-onboard your agent (writes to both runtimes)\n' +
|
|
273
|
-
' 3. metro doctor # verify everything works\n' +
|
|
274
|
-
' 4. metro # start the inbound stream\n');
|
|
275
|
-
}
|
|
276
|
-
else if (!anySkill) {
|
|
277
|
-
process.stdout.write('Next: `metro setup skill` to auto-onboard your agent. Then `metro doctor`, then `metro`.\n');
|
|
278
|
-
}
|
|
279
|
-
else {
|
|
280
|
-
process.stdout.write('Run `metro` to start the inbound stream, or `metro doctor` to verify.\n');
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
async function cmdSetupSkill(flags) {
|
|
284
|
-
const scope = flags.project ? 'project' : 'user';
|
|
285
|
-
if (flags.clear) {
|
|
286
|
-
const removed = [];
|
|
287
|
-
for (const runtime of SKILL_RUNTIMES) {
|
|
288
|
-
const dir = skillDir(runtime, scope);
|
|
289
|
-
if (existsSync(dir)) {
|
|
290
|
-
rmSync(dir, { recursive: true, force: true });
|
|
291
|
-
removed.push(dir);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
emitResult(flags, removed.length ? `removed:\n ${removed.join('\n ')}` : '(no skills installed at this scope)', { ok: true, cleared: removed });
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
if (!existsSync(BUNDLED_SKILL))
|
|
298
|
-
throw new Error(`bundled SKILL.md missing at ${BUNDLED_SKILL} (broken install?)`);
|
|
299
|
-
const written = SKILL_RUNTIMES.map(r => {
|
|
300
|
-
const dest = skillFile(r, scope);
|
|
301
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
302
|
-
copyFileSync(BUNDLED_SKILL, dest);
|
|
303
|
-
return dest;
|
|
304
|
-
});
|
|
305
|
-
emitResult(flags, `wrote skill (${scope}) to:\n ${written.join('\n ')}\n\nThe agent will pick it up on its next session start.`, { ok: true, scope, paths: written });
|
|
112
|
+
` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n`);
|
|
113
|
+
process.stdout.write(!tg && !dc
|
|
114
|
+
? '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'
|
|
115
|
+
: 'Run `metro` to start the orchestrator, or `metro doctor` to verify.\n');
|
|
306
116
|
}
|
|
307
117
|
async function cmdDoctor(flags) {
|
|
308
118
|
loadMetroEnv();
|
|
309
|
-
const checks = [];
|
|
310
|
-
// tokens
|
|
311
119
|
const cfg = configuredPlatforms();
|
|
312
|
-
checks
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
for (const [platform, getMe] of [['telegram', telegram.getMe], ['discord', discord.getMe]]) {
|
|
321
|
-
if (!cfg[platform]) {
|
|
322
|
-
checks.push({ name: platform, ok: null, detail: 'not configured' });
|
|
120
|
+
const checks = [{
|
|
121
|
+
name: 'tokens',
|
|
122
|
+
ok: cfg.telegram || cfg.discord,
|
|
123
|
+
detail: cfg.telegram || cfg.discord ? `loaded from ${existsSync(CONFIG_ENV_FILE) ? CONFIG_ENV_FILE : 'process env'}` : 'no platform configured — run `metro setup telegram|discord <token>`',
|
|
124
|
+
}];
|
|
125
|
+
for (const [p, getMe] of [['telegram', telegram.getMe], ['discord', discord.getMe]]) {
|
|
126
|
+
if (!cfg[p]) {
|
|
127
|
+
checks.push({ name: p, ok: null, detail: 'not configured' });
|
|
323
128
|
continue;
|
|
324
129
|
}
|
|
325
130
|
try {
|
|
326
131
|
const me = await getMe();
|
|
327
|
-
checks.push({ name:
|
|
132
|
+
checks.push({ name: p, ok: true, detail: `getMe → ${p === 'telegram' ? '@' : ''}${me.username}` });
|
|
328
133
|
}
|
|
329
134
|
catch (err) {
|
|
330
|
-
checks.push({ name:
|
|
135
|
+
checks.push({ name: p, ok: false, detail: errMsg(err) });
|
|
331
136
|
}
|
|
332
137
|
}
|
|
333
|
-
// tail process state. A stale lockfile (process gone) is informational —
|
|
334
|
-
// the next `metro` start auto-reclaims it, so we don't fail the doctor on it.
|
|
335
138
|
const lockFile = join(STATE_DIR, '.tail-lock');
|
|
336
|
-
if (!existsSync(lockFile))
|
|
337
|
-
checks.push({ name: '
|
|
338
|
-
|
|
339
|
-
else {
|
|
139
|
+
if (!existsSync(lockFile))
|
|
140
|
+
checks.push({ name: 'orchestrator', ok: null, detail: 'not running' });
|
|
141
|
+
else
|
|
340
142
|
try {
|
|
341
143
|
const pid = Number(readFileSync(lockFile, 'utf8').trim());
|
|
342
144
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
343
145
|
throw new Error('invalid pid');
|
|
344
146
|
process.kill(pid, 0);
|
|
345
|
-
checks.push({ name: '
|
|
147
|
+
checks.push({ name: 'orchestrator', ok: true, detail: `running (pid ${pid})` });
|
|
346
148
|
}
|
|
347
149
|
catch {
|
|
348
|
-
checks.push({ name: '
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
// skill install + freshness vs bundled
|
|
352
|
-
for (const runtime of SKILL_RUNTIMES) {
|
|
353
|
-
const present = SKILL_SCOPES.filter(s => existsSync(skillFile(runtime, s)));
|
|
354
|
-
if (present.length === 0) {
|
|
355
|
-
checks.push({ name: `skill: ${runtime}`, ok: null, detail: 'not installed — run `metro setup skill` (writes both runtimes)' });
|
|
356
|
-
continue;
|
|
150
|
+
checks.push({ name: 'orchestrator', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' });
|
|
357
151
|
}
|
|
358
|
-
|
|
359
|
-
const stale = bundled
|
|
360
|
-
? present.filter(s => !readFileSync(skillFile(runtime, s)).equals(bundled))
|
|
361
|
-
: [];
|
|
362
|
-
checks.push(stale.length === 0
|
|
363
|
-
? { name: `skill: ${runtime}`, ok: true, detail: present.join(' + ') }
|
|
364
|
-
: { name: `skill: ${runtime}`, ok: false, detail: `stale at ${stale.join(' + ')} — re-run \`metro setup skill${stale.includes('project') ? ' --project' : ''}\`` });
|
|
365
|
-
}
|
|
366
|
-
if (isJson(flags)) {
|
|
152
|
+
if (isJson(flags))
|
|
367
153
|
process.stdout.write(JSON.stringify({ checks }) + '\n');
|
|
368
|
-
}
|
|
369
154
|
else {
|
|
370
155
|
process.stdout.write('metro doctor\n\n');
|
|
371
|
-
for (const c of checks)
|
|
372
|
-
|
|
373
|
-
process.stdout.write(` ${icon} ${c.name.padEnd(20)} ${c.detail}\n`);
|
|
374
|
-
}
|
|
156
|
+
for (const c of checks)
|
|
157
|
+
process.stdout.write(` ${c.ok === true ? '✓' : c.ok === false ? '✗' : '–'} ${c.name.padEnd(15)} ${c.detail}\n`);
|
|
375
158
|
process.stdout.write('\n');
|
|
376
159
|
}
|
|
377
160
|
if (checks.some(c => c.ok === false))
|
|
378
161
|
throw exitErr('one or more checks failed', 3);
|
|
379
162
|
}
|
|
380
|
-
// ---------------- update ---------------------------------------------------
|
|
381
163
|
async function cmdUpdate(flags) {
|
|
382
|
-
// While metro is in prerelease, the @beta dist-tag is what we publish.
|
|
383
|
-
// After GA, swap to 'latest' (or auto-pick from current version's prerelease tag).
|
|
384
164
|
const tag = pkg.version.includes('-') ? 'beta' : 'latest';
|
|
385
165
|
const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
|
|
386
166
|
if (!res.ok)
|
|
387
167
|
throw new Error(`npm registry: ${res.status}`);
|
|
388
|
-
const
|
|
389
|
-
const latest = data['dist-tags']?.[tag];
|
|
168
|
+
const latest = (await res.json())['dist-tags']?.[tag];
|
|
390
169
|
if (!latest)
|
|
391
170
|
throw new Error(`no '${tag}' dist-tag for @stage-labs/metro`);
|
|
392
|
-
if (latest === pkg.version)
|
|
393
|
-
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
171
|
+
if (latest === pkg.version)
|
|
172
|
+
return emit(flags, `already on ${pkg.version} (latest ${tag})`, { ok: true, current: pkg.version, latest, upgraded: false });
|
|
396
173
|
const argv1 = process.argv[1] ?? '';
|
|
397
174
|
const spec = `@stage-labs/metro@${tag}`;
|
|
398
175
|
const argv = argv1.includes('/.bun/') || argv1.includes('\\bun\\') ? ['bun', 'add', '-g', spec]
|
|
399
176
|
: argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
|
|
400
177
|
: ['npm', 'install', '-g', spec];
|
|
401
|
-
if (isJson(flags))
|
|
402
|
-
process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' ')
|
|
403
|
-
|
|
404
|
-
else {
|
|
178
|
+
if (isJson(flags))
|
|
179
|
+
process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' ') }) + '\n');
|
|
180
|
+
else
|
|
405
181
|
process.stdout.write(`metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}\n`);
|
|
406
|
-
}
|
|
407
|
-
// Snapshot which skill destinations are installed BEFORE the package
|
|
408
|
-
// manager replaces our binary — after the spawn, BUNDLED_SKILL on disk
|
|
409
|
-
// points at the new content (npm install -g overwrites in place), so a
|
|
410
|
-
// fresh copyFileSync picks it up automatically.
|
|
411
|
-
const refreshTargets = SKILL_RUNTIMES.flatMap(r => SKILL_SCOPES.filter(s => existsSync(skillFile(r, s))).map(s => skillFile(r, s)));
|
|
412
182
|
await new Promise((resolve, reject) => {
|
|
413
183
|
const child = spawn(argv[0], argv.slice(1), { stdio: isJson(flags) ? 'ignore' : 'inherit' });
|
|
414
|
-
child.on('exit', code =>
|
|
184
|
+
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`)));
|
|
415
185
|
child.on('error', reject);
|
|
416
186
|
});
|
|
417
|
-
// Refresh installed skills from the (now-updated) bundled SKILL.md.
|
|
418
|
-
if (existsSync(BUNDLED_SKILL)) {
|
|
419
|
-
for (const dest of refreshTargets) {
|
|
420
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
421
|
-
copyFileSync(BUNDLED_SKILL, dest);
|
|
422
|
-
}
|
|
423
|
-
if (refreshTargets.length > 0 && !isJson(flags)) {
|
|
424
|
-
process.stdout.write(`\nrefreshed installed skills:\n ${refreshTargets.join('\n ')}\n`);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
// ---------------- inbound action commands ----------------------------------
|
|
429
|
-
async function cmdSend(flags) {
|
|
430
|
-
const addr = resolveAddr(flags, false);
|
|
431
|
-
const text = await resolveText(flags);
|
|
432
|
-
const sentMessageId = addr.platform === 'telegram'
|
|
433
|
-
? String((await tg('sendMessage', buildSendBody(addr.chat, text, readTelegramOpts(flags)))).message_id)
|
|
434
|
-
: await discord.sendMessage(addr.chat, text);
|
|
435
|
-
emitResult(flags, 'sent', { ok: true, platform: addr.platform, to: String(flags.to), sent_message_id: sentMessageId });
|
|
436
187
|
}
|
|
437
|
-
async function cmdReply(flags) {
|
|
438
|
-
const addr = resolveAddr(flags, true);
|
|
439
|
-
const text = await resolveText(flags);
|
|
440
|
-
let sentMessageId;
|
|
441
|
-
if (addr.platform === 'telegram') {
|
|
442
|
-
const body = buildSendBody(addr.chat, text, readTelegramOpts(flags));
|
|
443
|
-
body.reply_parameters = { message_id: tgMessageId(addr), allow_sending_without_reply: true };
|
|
444
|
-
sentMessageId = String((await tg('sendMessage', body)).message_id);
|
|
445
|
-
}
|
|
446
|
-
else {
|
|
447
|
-
sentMessageId = await discord.replyToMessage(addr.chat, addr.messageId, text);
|
|
448
|
-
}
|
|
449
|
-
signalReplyComplete(addr.platform, addr.chat);
|
|
450
|
-
await clearReaction(addr).catch(err => log.warn({ err: errMsg(err) }, 'clear-reaction failed'));
|
|
451
|
-
emitResult(flags, 'sent', { ok: true, platform: addr.platform, to: String(flags.to), sent_message_id: sentMessageId });
|
|
452
|
-
}
|
|
453
|
-
async function cmdReact(flags) {
|
|
454
|
-
const addr = resolveAddr(flags, true);
|
|
455
|
-
const emoji = typeof flags.emoji === 'string' ? flags.emoji : '';
|
|
456
|
-
if (addr.platform === 'telegram') {
|
|
457
|
-
await tg('setMessageReaction', { chat_id: addr.chat, message_id: tgMessageId(addr), reaction: emoji ? [{ type: 'emoji', emoji }] : [] });
|
|
458
|
-
}
|
|
459
|
-
else {
|
|
460
|
-
await discord.setReaction(addr.chat, addr.messageId, emoji);
|
|
461
|
-
}
|
|
462
|
-
emitResult(flags, emoji ? 'reacted' : 'cleared', { ok: true, to: String(flags.to), emoji, action: emoji ? 'reacted' : 'cleared' });
|
|
463
|
-
}
|
|
464
|
-
async function cmdEdit(flags) {
|
|
465
|
-
const addr = resolveAddr(flags, true);
|
|
466
|
-
const text = await resolveText(flags);
|
|
467
|
-
if (addr.platform === 'telegram') {
|
|
468
|
-
const body = buildSendBody(addr.chat, text, readTelegramOpts(flags));
|
|
469
|
-
body.message_id = tgMessageId(addr);
|
|
470
|
-
await tg('editMessageText', body);
|
|
471
|
-
}
|
|
472
|
-
else {
|
|
473
|
-
await discord.editMessage(addr.chat, addr.messageId, text);
|
|
474
|
-
}
|
|
475
|
-
emitResult(flags, 'edited', { ok: true, to: String(flags.to) });
|
|
476
|
-
}
|
|
477
|
-
async function cmdDownload(flags) {
|
|
478
|
-
const addr = resolveAddr(flags, true);
|
|
479
|
-
const outDir = typeof flags.out === 'string' ? flags.out : join(STATE_DIR, 'attachments');
|
|
480
|
-
mkdirSync(outDir, { recursive: true });
|
|
481
|
-
const images = addr.platform === 'telegram'
|
|
482
|
-
? await Promise.all(telegram.getCachedAttachments(addr.chat, tgMessageId(addr)).map(a => telegram.downloadAttachment(a.file_id, a.mime)))
|
|
483
|
-
: await discord.fetchAttachments(addr.chat, addr.messageId);
|
|
484
|
-
if (images.length === 0) {
|
|
485
|
-
if (isJson(flags))
|
|
486
|
-
process.stdout.write(JSON.stringify({ ok: true, images: [] }) + '\n');
|
|
487
|
-
else
|
|
488
|
-
process.stderr.write('no image attachments found\n');
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
const safeChat = addr.chat.replace(/[^\w-]/g, '_');
|
|
492
|
-
const safeMsg = (addr.messageId ?? '').replace(/[^\w-]/g, '_');
|
|
493
|
-
const out = images.map((img, i) => {
|
|
494
|
-
const ext = EXT_FROM_MIME[img.mime] ?? 'bin';
|
|
495
|
-
const path = join(outDir, `${addr.platform}_${safeChat}_${safeMsg}_${i}.${ext}`);
|
|
496
|
-
writeFileSync(path, Buffer.from(img.data, 'base64'));
|
|
497
|
-
return { path, mime: img.mime };
|
|
498
|
-
});
|
|
499
|
-
if (isJson(flags))
|
|
500
|
-
process.stdout.write(JSON.stringify({ ok: true, images: out }) + '\n');
|
|
501
|
-
else
|
|
502
|
-
process.stdout.write(out.map(o => o.path).join('\n') + '\n');
|
|
503
|
-
}
|
|
504
|
-
async function cmdFetch(flags) {
|
|
505
|
-
const addr = resolveAddr(flags, false);
|
|
506
|
-
if (addr.platform !== 'discord') {
|
|
507
|
-
throw new Error('metro fetch is Discord-only — Telegram has no recent-messages API for bots');
|
|
508
|
-
}
|
|
509
|
-
const limit = Number(flags.limit ?? 10);
|
|
510
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
511
|
-
throw new Error('--limit must be an integer 1–100');
|
|
512
|
-
const msgs = await discord.fetchRecentMessages(addr.chat, limit);
|
|
513
|
-
if (isJson(flags)) {
|
|
514
|
-
process.stdout.write(JSON.stringify(msgs) + '\n');
|
|
515
|
-
}
|
|
516
|
-
else {
|
|
517
|
-
const text = msgs.map(m => `[message_id=${m.message_id} ${m.timestamp}] ${m.author}: ${m.text}`).join('\n');
|
|
518
|
-
process.stdout.write((text || '(channel is empty)') + '\n');
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
// ---------------- main dispatcher ------------------------------------------
|
|
522
188
|
const COMMANDS = {
|
|
523
|
-
setup:
|
|
524
|
-
doctor: (_,
|
|
525
|
-
update: (_,
|
|
526
|
-
reply: (_, flags) => cmdReply(flags),
|
|
527
|
-
react: (_, flags) => cmdReact(flags),
|
|
528
|
-
edit: (_, flags) => cmdEdit(flags),
|
|
529
|
-
send: (_, flags) => cmdSend(flags),
|
|
530
|
-
download: (_, flags) => cmdDownload(flags),
|
|
531
|
-
fetch: (_, flags) => cmdFetch(flags),
|
|
189
|
+
setup: cmdSetup,
|
|
190
|
+
doctor: (_, f) => cmdDoctor(f),
|
|
191
|
+
update: (_, f) => cmdUpdate(f),
|
|
532
192
|
};
|
|
533
|
-
const NEEDS_ENV = new Set(['doctor', 'reply', 'react', 'edit', 'send', 'download', 'fetch']);
|
|
534
193
|
async function main() {
|
|
535
194
|
const cmd = process.argv[2];
|
|
536
|
-
if (cmd === '--version' || cmd === '-v')
|
|
537
|
-
process.stdout.write(`${pkg.version}\n`);
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (cmd === '--help' || cmd === '-h') {
|
|
541
|
-
process.stdout.write(USAGE);
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
// Bare `metro` runs the inbound stream — the primary action; one-shot
|
|
545
|
-
// subcommands are the secondary surface (matches claude / redis-server /
|
|
546
|
-
// nginx convention where the daemon's the default).
|
|
195
|
+
if (cmd === '--version' || cmd === '-v')
|
|
196
|
+
return void process.stdout.write(`${pkg.version}\n`);
|
|
197
|
+
if (cmd === '--help' || cmd === '-h')
|
|
198
|
+
return void process.stdout.write(USAGE);
|
|
547
199
|
if (!cmd) {
|
|
548
|
-
await import('./
|
|
200
|
+
await import('./orchestrator.js');
|
|
549
201
|
return;
|
|
550
202
|
}
|
|
551
203
|
const handler = COMMANDS[cmd];
|
|
@@ -554,19 +206,15 @@ async function main() {
|
|
|
554
206
|
process.exit(1);
|
|
555
207
|
}
|
|
556
208
|
const { positional, flags } = parseArgs(process.argv.slice(3));
|
|
557
|
-
if (NEEDS_ENV.has(cmd))
|
|
558
|
-
loadMetroEnv();
|
|
559
209
|
try {
|
|
560
210
|
await handler(positional, flags);
|
|
561
211
|
}
|
|
562
212
|
catch (err) {
|
|
563
213
|
const code = err.code;
|
|
564
|
-
if (isJson(flags))
|
|
214
|
+
if (isJson(flags))
|
|
565
215
|
process.stdout.write(JSON.stringify({ ok: false, error: errMsg(err), code: code ?? 1 }) + '\n');
|
|
566
|
-
|
|
567
|
-
else {
|
|
216
|
+
else
|
|
568
217
|
process.stderr.write(`error: ${errMsg(err)}\n`);
|
|
569
|
-
}
|
|
570
218
|
process.exit(typeof code === 'number' ? code : 1);
|
|
571
219
|
}
|
|
572
220
|
}
|