@stage-labs/metro 0.1.0-beta.96 → 0.1.0-beta.97

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/dist/plugin.js CHANGED
@@ -1,9 +1,39 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { MARKETPLACE, runtimeStore } from './runtime-install.js';
5
6
  const MARKETPLACE_REPO = 'bonustrack/metro';
7
+ const MARKETPLACE_NAME = 'metro';
6
8
  const PLUGIN_SPEC = 'metro@metro';
9
+ export function marketplaceSource(store = runtimeStore()) {
10
+ const bundled = join(store, MARKETPLACE);
11
+ return existsSync(join(bundled, '.claude-plugin', 'marketplace.json')) ? bundled : MARKETPLACE_REPO;
12
+ }
13
+ const realish = (path) => {
14
+ try {
15
+ return realpathSync(path);
16
+ }
17
+ catch {
18
+ return path;
19
+ }
20
+ };
21
+ export function registeredMarketplacePath(listJson) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(listJson);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ if (!Array.isArray(parsed))
30
+ return undefined;
31
+ const mine = parsed.find((m) => typeof m === 'object' && m !== null && m.name === MARKETPLACE_NAME);
32
+ if (mine === undefined)
33
+ return undefined;
34
+ const at = mine.installLocation ?? mine.path;
35
+ return typeof at === 'string' ? at : null;
36
+ }
7
37
  export function installPathFrom(raw) {
8
38
  let parsed;
9
39
  try {
@@ -58,21 +88,31 @@ export function syncPluginServers() {
58
88
  runClaude(['plugin', 'update', PLUGIN_SPEC]);
59
89
  return true;
60
90
  }
91
+ function ensureMarketplace(source) {
92
+ const listed = runClaude(['plugin', 'marketplace', 'list', '--json']);
93
+ const at = registeredMarketplacePath(listed.output);
94
+ if (at !== undefined && at !== null && (at === source || realish(at) === realish(source)))
95
+ return { ok: true, output: '' };
96
+ if (at !== undefined)
97
+ runClaude(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
98
+ return runClaude(['plugin', 'marketplace', 'add', source]);
99
+ }
61
100
  export async function installPlugin() {
62
- const added = runClaude(['plugin', 'marketplace', 'add', MARKETPLACE_REPO]);
101
+ const source = marketplaceSource();
102
+ const added = ensureMarketplace(source);
63
103
  if (!added.ok && !tolerable(added.output)) {
64
104
  process.stderr.write(added.output);
65
105
  return 1;
66
106
  }
67
- const installed = runClaude(['plugin', 'install', PLUGIN_SPEC]);
107
+ const installed = runClaude(['plugin', 'install', PLUGIN_SPEC, '-y', '--scope', 'user']);
68
108
  if (!installed.ok && !tolerable(installed.output)) {
69
109
  process.stderr.write(installed.output);
70
110
  return 1;
71
111
  }
72
- process.stderr.write('Claude Code plugin installed.\n');
112
+ process.stderr.write(`Claude Code plugin installed from ${source}.\n`);
73
113
  if (syncPluginServers()) {
74
114
  process.stderr.write('Connector servers loaded — new Claude sessions have them; ' +
75
- 'run /reload-plugins in any session already open.\n');
115
+ 'run /reload-plugins --force in any session already open.\n');
76
116
  return 0;
77
117
  }
78
118
  process.stderr.write('Start the daemon on this machine (metro serve), then run /metro:refresh inside Claude Code.\n');
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { localStations } from './local.js';
6
6
  import { STORE_ENTRY, daemonEntry, findBun, runtimeDir } from './runtime.js';
7
7
  export const MANIFEST_FILE = 'stations.json';
8
+ export const MARKETPLACE = 'marketplace';
8
9
  const METRO_SOURCES = join('node_modules', '@metro-labs');
9
10
  const INSTALL_TIMEOUT_MS = 15 * 60_000;
10
11
  export function runtimeStore() {
@@ -49,6 +50,9 @@ function syncSources(sources, store) {
49
50
  return false;
50
51
  rmSync(join(store, METRO_SOURCES), { recursive: true, force: true });
51
52
  cpSync(join(sources, METRO_SOURCES), join(store, METRO_SOURCES), { recursive: true });
53
+ rmSync(join(store, MARKETPLACE), { recursive: true, force: true });
54
+ if (existsSync(join(sources, MARKETPLACE)))
55
+ cpSync(join(sources, MARKETPLACE), join(store, MARKETPLACE), { recursive: true });
52
56
  if (stamp !== null)
53
57
  writeFileSync(join(store, 'runtime.json'), stamp);
54
58
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.96",
3
+ "version": "0.1.0-beta.97",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "metro",
3
+ "description": "Metro — connector relay and chat bridge for agents",
4
+ "owner": { "name": "Bonustrack", "url": "https://metro.box" },
5
+ "plugins": [
6
+ {
7
+ "name": "metro",
8
+ "source": "./plugin",
9
+ "description": "Sign in as one metro agent and use the connectors it holds as MCP servers through metro's relay, credentials injected server-side."
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "metro",
3
+ "version": "0.1.0-beta.97",
4
+ "description": "Metro connectors for Claude Code. Runs beside the metro daemon on this machine: each connector the agent holds is one MCP server relayed through the daemon, the daemon keeps that list current on its own, and /reload-plugins picks up a new one without leaving your session. No vendor credential ever sits in a config file.",
5
+ "author": {
6
+ "name": "Bonustrack",
7
+ "url": "https://metro.box"
8
+ },
9
+ "repository": "https://github.com/bonustrack/metro",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "metro",
13
+ "mcp",
14
+ "connectors",
15
+ "relay"
16
+ ],
17
+ "skills": "./skills/"
18
+ }
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
8
+ const mcpPath = join(pluginRoot, '.mcp.json');
9
+ const NAME_PREFIX = 'metro.box ';
10
+
11
+ const localUrl = () => `http://127.0.0.1:${Number(process.env.METRO_WEBHOOK_PORT) || 8420}`;
12
+
13
+ function agentsDir() {
14
+ const explicit = process.env.METRO_AGENTS_DIR?.trim();
15
+ return explicit ? explicit : join(homedir(), '.metro', 'agents');
16
+ }
17
+
18
+ function localAgents() {
19
+ const dir = agentsDir();
20
+ if (!existsSync(dir)) return [];
21
+ const out = [];
22
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
23
+ if (!entry.isDirectory()) continue;
24
+ const path = join(dir, entry.name, 'agent.json');
25
+ if (!existsSync(path)) continue;
26
+ try {
27
+ const file = JSON.parse(readFileSync(path, 'utf8'));
28
+ if (typeof file.id === 'string' && typeof file.name === 'string' && typeof file.key === 'string')
29
+ out.push({ id: file.id, name: file.name, key: file.key });
30
+ } catch {
31
+ continue;
32
+ }
33
+ }
34
+ return out.sort((a, b) => a.name.localeCompare(b.name));
35
+ }
36
+
37
+ function pickAgent() {
38
+ const agents = localAgents();
39
+ const wanted = process.env.METRO_AGENT?.trim();
40
+ if (wanted) {
41
+ const found = agents.find((a) => a.name === wanted || a.id === wanted);
42
+ if (!found) throw new Error(`no local agent named '${wanted}' in ${agentsDir()}`);
43
+ return found;
44
+ }
45
+ if (agents.length === 1) return agents[0];
46
+ throw new Error(
47
+ agents.length === 0
48
+ ? 'no agent on this machine yet — start metro serve and create or restore one in the web UI'
49
+ : `several agents on this machine — set METRO_AGENT to one of: ${agents.map((a) => a.name).join(', ')}`,
50
+ );
51
+ }
52
+
53
+ async function api(path, key) {
54
+ let res;
55
+ try {
56
+ res = await fetch(`${localUrl()}${path}`, { headers: { authorization: `Bearer ${key}` } });
57
+ } catch {
58
+ throw new Error(`no metro daemon on ${localUrl()} — start one with: metro serve`);
59
+ }
60
+ const body = await res.json().catch(() => null);
61
+ if (!res.ok) {
62
+ const detail = typeof body?.error === 'string' ? body.error : `the daemon answered ${res.status}`;
63
+ const err = new Error(detail);
64
+ err.status = res.status;
65
+ throw err;
66
+ }
67
+ return body;
68
+ }
69
+
70
+ function serverEntries(rawJson) {
71
+ const servers = JSON.parse(rawJson)?.mcpServers;
72
+ if (typeof servers !== 'object' || servers === null)
73
+ throw new Error('the daemon returned an unexpected mcpServers shape');
74
+ const out = {};
75
+ for (const [key, value] of Object.entries(servers)) {
76
+ const name = key.startsWith(NAME_PREFIX) ? key.slice(NAME_PREFIX.length) : key;
77
+ out[name] = {
78
+ type: value.type ?? 'http',
79
+ url: value.url,
80
+ headersHelper: 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers',
81
+ };
82
+ }
83
+ return out;
84
+ }
85
+
86
+ async function refresh() {
87
+ const agent = pickAgent();
88
+ const body = await api('/api/cli/mcp', agent.key);
89
+ const servers = serverEntries(body.json);
90
+ writeFileSync(mcpPath, `${JSON.stringify(servers, null, 2)}\n`);
91
+ const names = Object.keys(servers);
92
+ process.stdout.write(
93
+ `agent '${agent.name}': ${names.length} server(s) written to the plugin\n` +
94
+ (names.length ? ` ${names.join('\n ')}\n` : '') +
95
+ 'Run /reload-plugins to apply in this session.\n',
96
+ );
97
+ }
98
+
99
+ function headers() {
100
+ let agent;
101
+ try {
102
+ agent = pickAgent();
103
+ } catch (err) {
104
+ process.stderr.write(`metro: ${err.message}\n`);
105
+ process.stdout.write('{}\n');
106
+ return;
107
+ }
108
+ process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${agent.key}` })}\n`);
109
+ }
110
+
111
+ const command = process.argv[2];
112
+ const run = { refresh, headers: () => Promise.resolve(headers()) }[command];
113
+
114
+ if (run === undefined) {
115
+ process.stderr.write('usage: metro-plugin.mjs <refresh|headers>\n');
116
+ process.exit(1);
117
+ }
118
+
119
+ run().catch((err) => {
120
+ process.stderr.write(`metro: ${err.message}\n`);
121
+ process.exit(err.status === 401 ? 2 : 1);
122
+ });
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: refresh
3
+ description: Rewrite the plugin's MCP servers from the metro daemon's connector list by hand, for when the daemon could not keep the list current itself.
4
+ ---
5
+
6
+ The metro daemon writes this plugin's MCP server list itself whenever a connector is added, renamed or removed, so this is the fallback for when it could not: the daemon was not running at the time, or a plugin update reset the file.
7
+
8
+ 1. Run with Bash:
9
+
10
+ ```
11
+ node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" refresh
12
+ ```
13
+
14
+ 2. On success, relay the script's output verbatim and tell the user to run `/reload-plugins --force`, which re-reads the plugin from disk; a plain `/reload-plugins` reuses a cached manifest and keeps the old list. You cannot run that command for them.
15
+ 3. If the script says there is no daemon, tell the user to start one with `metro serve` and stop. If it says there is no agent yet, tell them to create or restore one in the web UI the daemon links to.
@@ -22,6 +22,7 @@ import { localAgentKey } from '../stations/materialize.js';
22
22
  import { agentsDir, fileSource } from '../agents/files.js';
23
23
  import { ConnectorWatch } from '../connectors/watch.js';
24
24
  import { syncPluginServers } from '../connectors/plugin-sync.js';
25
+ import { ensureMetroPlugin } from '../claude/plugin-install.js';
25
26
  import { applyLocalOwner } from './local-owner.js';
26
27
  import { localOwner } from '../agents/file-admin.js';
27
28
  import { ensureStationDeps } from '../stations/runtime-deps.js';
@@ -150,6 +151,14 @@ async function main(): Promise<void> {
150
151
  'dispatcher ready',
151
152
  );
152
153
  markDaemonReady();
154
+ ensureMetroPlugin()
155
+ .then((outcome) => {
156
+ log.info({ outcome }, 'plugin: metro plugin for Claude Code');
157
+ if (outcome !== 'skipped') syncPluginServers();
158
+ })
159
+ .catch((err: unknown) => {
160
+ log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
161
+ });
153
162
  }
154
163
 
155
164
  const SHUTDOWN_TIMEOUT_MS = 3_000;
@@ -0,0 +1,128 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { log } from '@metro-labs/core/log';
7
+
8
+ const MARKETPLACE = 'metro';
9
+ const PLUGIN = 'metro@metro';
10
+ const STEP_MS = 120_000;
11
+ const SAID_MAX = 300;
12
+
13
+ export interface Answer {
14
+ status: number;
15
+ stdout: string;
16
+ stderr: string;
17
+ }
18
+
19
+ export type Run = (args: string[]) => Promise<Answer>;
20
+
21
+ export interface PluginInstallDeps {
22
+ run: Run | null;
23
+ marketplaceDir: string | null;
24
+ }
25
+
26
+ export type PluginOutcome = 'skipped' | 'unchanged' | 'installed' | 'updated' | 'failed';
27
+
28
+ export function stagedMarketplaceDir(env: NodeJS.ProcessEnv = process.env): string | null {
29
+ const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
30
+ if (store === '') return null;
31
+ const dir = join(store, 'marketplace');
32
+ return existsSync(join(dir, '.claude-plugin', 'marketplace.json')) ? dir : null;
33
+ }
34
+
35
+ export function stagedPluginVersion(dir: string): string | null {
36
+ try {
37
+ const parsed: unknown = JSON.parse(readFileSync(join(dir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8'));
38
+ return isRecord(parsed) && typeof parsed.version === 'string' ? parsed.version : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ const runner =
45
+ (bin: string): Run =>
46
+ (args) =>
47
+ new Promise((resolve) => {
48
+ execFile(bin, args, { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
49
+ const status = err === null ? 0 : typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 1;
50
+ resolve({ status, stdout, stderr });
51
+ });
52
+ });
53
+
54
+ export async function findClaude(candidates = ['claude', join(homedir(), '.local', 'bin', 'claude')]): Promise<Run | null> {
55
+ for (const bin of candidates) {
56
+ const run = runner(bin);
57
+ if ((await run(['--version'])).status === 0) return run;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function listOf(stdout: string): Record<string, unknown>[] {
63
+ try {
64
+ const parsed: unknown = JSON.parse(stdout);
65
+ return Array.isArray(parsed) ? parsed.filter(isRecord) : [];
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ const realish = (path: string): string => {
72
+ try {
73
+ return realpathSync(path);
74
+ } catch {
75
+ return path;
76
+ }
77
+ };
78
+
79
+ const samePath = (a: string | null, b: string): boolean => a !== null && realish(a) === realish(b);
80
+
81
+ async function step(run: Run, args: string[]): Promise<boolean> {
82
+ const answer = await run(args);
83
+ if (answer.status === 0) {
84
+ log.info({ args }, 'plugin: claude');
85
+ return true;
86
+ }
87
+ log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'plugin: claude refused');
88
+ return false;
89
+ }
90
+
91
+ async function marketplacePath(run: Run): Promise<string | null | undefined> {
92
+ const mine = listOf((await run(['plugin', 'marketplace', 'list', '--json'])).stdout).find((m) => m.name === MARKETPLACE);
93
+ if (mine === undefined) return undefined;
94
+ const at = mine.installLocation ?? mine.path;
95
+ return typeof at === 'string' ? at : null;
96
+ }
97
+
98
+ async function ensureMarketplace(run: Run, dir: string): Promise<boolean> {
99
+ const at = await marketplacePath(run);
100
+ if (at !== undefined && samePath(at, dir)) return true;
101
+ if (at !== undefined) await step(run, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
102
+ return step(run, ['plugin', 'marketplace', 'add', dir]);
103
+ }
104
+
105
+ async function installedVersion(run: Run): Promise<string | null> {
106
+ const mine = listOf((await run(['plugin', 'list', '--json'])).stdout).find((p) => p.id === PLUGIN);
107
+ return mine !== undefined && typeof mine.version === 'string' ? mine.version : null;
108
+ }
109
+
110
+ async function ensureInstalled(run: Run, wanted: string | null): Promise<PluginOutcome> {
111
+ const have = await installedVersion(run);
112
+ if (have === null) return (await step(run, ['plugin', 'install', PLUGIN, '-y', '--scope', 'user'])) ? 'installed' : 'failed';
113
+ if (wanted === null || have === wanted) return 'unchanged';
114
+ await step(run, ['plugin', 'marketplace', 'update', MARKETPLACE]);
115
+ return (await step(run, ['plugin', 'update', PLUGIN, '-y'])) ? 'updated' : 'failed';
116
+ }
117
+
118
+ export async function ensureMetroPlugin(deps?: Partial<PluginInstallDeps>): Promise<PluginOutcome> {
119
+ const dir = deps?.marketplaceDir === undefined ? stagedMarketplaceDir() : deps.marketplaceDir;
120
+ if (dir === null) return 'skipped';
121
+ const run = deps?.run === undefined ? await findClaude() : deps.run;
122
+ if (run === null) {
123
+ log.info('plugin: claude is not on this machine, so metro leaves its Claude Code plugin alone');
124
+ return 'skipped';
125
+ }
126
+ if (!(await ensureMarketplace(run, dir))) return 'failed';
127
+ return ensureInstalled(run, stagedPluginVersion(dir));
128
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.96"
2
+ "version": "0.1.0-beta.97"
3
3
  }