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

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.98",
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.98",
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.
@@ -74,6 +74,13 @@ export interface AccountApiDeps {
74
74
  allowlist: string[],
75
75
  ) => Promise<string[]>;
76
76
  recentSenders: (station: StationName, accountId: string) => RecentSender[];
77
+ setAccountEnabled: (
78
+ subject: string,
79
+ agentId: string,
80
+ station: StationName,
81
+ accountId: string,
82
+ enabled: boolean,
83
+ ) => Promise<boolean>;
77
84
  }
78
85
 
79
86
  export type AccountRoute =
@@ -82,7 +89,8 @@ export type AccountRoute =
82
89
  | { kind: 'step'; attachId: string }
83
90
  | { kind: 'account'; station: StationName; accountId: string }
84
91
  | { kind: 'allowlist'; station: StationName; accountId: string }
85
- | { kind: 'senders'; station: StationName; accountId: string };
92
+ | { kind: 'senders'; station: StationName; accountId: string }
93
+ | { kind: 'enabled'; station: StationName; accountId: string };
86
94
 
87
95
  export const ATTACHABLE: string[] = [
88
96
  ...ATTACHABLE_STATIONS,
@@ -96,6 +104,7 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
96
104
  account: ['DELETE'],
97
105
  allowlist: ['PUT'],
98
106
  senders: ['GET'],
107
+ enabled: ['PUT'],
99
108
  };
100
109
 
101
110
  function twoSegmentRoute(head: string, tail: string): AccountRoute | null {
@@ -121,7 +130,7 @@ export function accountRoute(rest: string[]): AccountRoute | null {
121
130
  }
122
131
 
123
132
  function accountSubRoute(head: string, tail: string, sub: string | undefined): AccountRoute | null {
124
- if (!isStationName(head) || (sub !== 'allowlist' && sub !== 'senders')) return null;
133
+ if (!isStationName(head) || (sub !== 'allowlist' && sub !== 'senders' && sub !== 'enabled')) return null;
125
134
  const accountId = parseAccountId(tail);
126
135
  return accountId === null ? null : { kind: sub, station: head, accountId };
127
136
  }
@@ -294,6 +303,27 @@ async function handleAllowlist(
294
303
  });
295
304
  }
296
305
 
306
+ async function handleEnabled(
307
+ req: IncomingMessage,
308
+ res: ServerResponse,
309
+ deps: AccountApiDeps,
310
+ session: ApiSession,
311
+ agentId: string,
312
+ target: { station: StationName; accountId: string },
313
+ ): Promise<void> {
314
+ const wanted = bodyField(await readJsonBody(req), 'enabled');
315
+ if (typeof wanted !== 'boolean') throw new ApiError('enabled must be true or false', 400);
316
+ const enabled = await deps.setAccountEnabled(session.subject, agentId, target.station, target.accountId, wanted);
317
+ log.info({ agentId, station: target.station, account: target.accountId, enabled }, 'account-api: account enabled flag set');
318
+ sendJson(req, res, 200, {
319
+ agentId,
320
+ station: target.station,
321
+ accountId: target.accountId,
322
+ enabled,
323
+ activated: await activate(deps, target.station),
324
+ });
325
+ }
326
+
297
327
  async function handleSession(
298
328
  req: IncomingMessage,
299
329
  res: ServerResponse,
@@ -345,6 +375,7 @@ async function dispatchRoute(
345
375
  if (route.kind === 'step')
346
376
  return handleStep(req, res, deps, ownerOf(session, agentId), route.attachId);
347
377
  if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, session, agentId, route);
378
+ if (route.kind === 'enabled') return handleEnabled(req, res, deps, session, agentId, route);
348
379
  if (route.kind === 'senders') {
349
380
  sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
350
381
  return;
@@ -47,7 +47,7 @@ function stationOf(raw: unknown): LoadedAccount {
47
47
  const allowlist = Array.isArray(raw.allowlist)
48
48
  ? raw.allowlist.filter((s): s is string => typeof s === 'string')
49
49
  : null;
50
- return { station: raw.station as LoadedAccount['station'], id: raw.id, allowlist, config: raw.config };
50
+ return { station: raw.station as LoadedAccount['station'], id: raw.id, allowlist, enabled: raw.enabled !== false, config: raw.config };
51
51
  }
52
52
 
53
53
  function connectorOf(raw: unknown): LoadedConnector {
@@ -305,7 +305,7 @@ export async function localAttachAccount(
305
305
  if (typeof token === 'string') assertTokenFree(storedAgents(dir), station, token);
306
306
  const taken = new Set(stored.file.stations.map((a) => a.id));
307
307
  const accountId = freshId(taken);
308
- stored.file.stations.push({ station, id: accountId, allowlist: ['*'], config });
308
+ stored.file.stations.push({ station, id: accountId, allowlist: ['*'], enabled: true, config });
309
309
  save(stored);
310
310
  return Promise.resolve({ agentId, station, accountId });
311
311
  }
@@ -326,6 +326,22 @@ export async function localSetAllowlist(
326
326
  return Promise.resolve(allowlist);
327
327
  }
328
328
 
329
+ export async function localSetAccountEnabled(
330
+ subject: string,
331
+ agentId: string,
332
+ station: StationName,
333
+ accountId: string,
334
+ enabled: boolean,
335
+ dir = agentsDir(),
336
+ ): Promise<boolean> {
337
+ const stored = ownedOrThrow(subject, agentId, dir);
338
+ const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
339
+ if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
340
+ account.enabled = enabled;
341
+ save(stored);
342
+ return Promise.resolve(enabled);
343
+ }
344
+
329
345
  export async function localDetachAccount(
330
346
  subject: string,
331
347
  agentId: string,
@@ -56,7 +56,7 @@ function stationOf(raw: unknown, path: string, index: number): LoadedAccount {
56
56
  if (typeof id !== 'string' || !ID_RE.test(id))
57
57
  fail(path, `${where}.id is not an 11-character id`);
58
58
  if (!isRecord(config)) fail(path, `${where}.config is not an object`);
59
- return { station, id, allowlist: allowlistOf(raw.allowlist, path, where), config };
59
+ return { station, id, allowlist: allowlistOf(raw.allowlist, path, where), enabled: raw.enabled !== false, config };
60
60
  }
61
61
 
62
62
  function optionalMatch(
@@ -8,6 +8,7 @@ const mapKey = (station: string, accountId: string): string =>
8
8
  let agentMap: AgentMap = {};
9
9
  let agentNames: AgentNameMap = {};
10
10
  let allowlistMap: AllowlistMap = {};
11
+ let disabledAccounts = new Set<string>();
11
12
 
12
13
  export function setAgentMap(map: AgentMap, names: AgentNameMap): void {
13
14
  agentMap = map;
@@ -18,6 +19,12 @@ export function setAllowlistMap(map: AllowlistMap): void {
18
19
  allowlistMap = map;
19
20
  }
20
21
 
22
+ export function setDisabledAccounts(ids: Set<string>): void {
23
+ disabledAccounts = ids;
24
+ }
25
+
26
+ export const accountEnabled = (station: string, accountId: string): boolean => !disabledAccounts.has(mapKey(station, accountId));
27
+
21
28
  export function accountFromLine(
22
29
  line: string,
23
30
  ): { station: string; accountId: string } | undefined {
@@ -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
+ }
@@ -5,7 +5,7 @@ import {
5
5
  } from '../stations/registry.js';
6
6
  import { listEndpoints } from '../net/tunnel.js';
7
7
  import { hookUrl } from '../stations/attach.js';
8
- import { agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
8
+ import { accountEnabled, agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
9
9
 
10
10
  const accountId = (acc: unknown): string | undefined => {
11
11
  const id = (acc as { id?: unknown }).id;
@@ -25,7 +25,8 @@ function withAgentId(station: string, acc: unknown): unknown {
25
25
  const agentId = agentIdForAccount(station, id);
26
26
  if (agentId === undefined) return acc;
27
27
  const allowlist = allowlistForAccount(station, id);
28
- return allowlist === undefined ? { ...rec, agentId } : { ...rec, agentId, allowlist };
28
+ const tagged = allowlist === undefined ? { ...rec, agentId } : { ...rec, agentId, allowlist };
29
+ return accountEnabled(station, id) ? tagged : { ...tagged, enabled: false };
29
30
  }
30
31
 
31
32
  export function attachAgentIds(
@@ -36,6 +36,7 @@ import {
36
36
  localDeleteAgent,
37
37
  localDetachAccount,
38
38
  localSetAllowlist,
39
+ localSetAccountEnabled,
39
40
  localImportAgent,
40
41
  localListAgents,
41
42
  localOwnedAgentOrThrow,
@@ -103,6 +104,7 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
103
104
  detachAccount: localDetachAccount,
104
105
  syncStations: deps.syncStations,
105
106
  setAllowlist: localSetAllowlist,
107
+ setAccountEnabled: localSetAccountEnabled,
106
108
  recentSenders,
107
109
  reloadAgents: deps.reloadAgents,
108
110
  };
@@ -13,6 +13,7 @@ import { trainsDir } from '../boot/paths.js';
13
13
  import {
14
14
  setAgentMap,
15
15
  setAllowlistMap,
16
+ setDisabledAccounts,
16
17
  type AgentMap,
17
18
  type AgentNameMap,
18
19
  type AllowlistMap,
@@ -30,6 +31,7 @@ export interface LoadedAccount {
30
31
  station: StationName;
31
32
  id: string;
32
33
  allowlist: string[] | null;
34
+ enabled?: boolean;
33
35
  config: Record<string, unknown>;
34
36
  }
35
37
 
@@ -126,6 +128,7 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
126
128
  const map: AgentMap = {};
127
129
  const names: AgentNameMap = {};
128
130
  const allow: AllowlistMap = {};
131
+ const disabled = new Set<string>();
129
132
  for (const agent of list) {
130
133
  names[agent.id] = agent.name;
131
134
  for (const a of agent.accounts) {
@@ -138,6 +141,10 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
138
141
  }
139
142
  map[`${a.station}/${a.id}`] = agent.id;
140
143
  if (a.allowlist) allow[`${a.station}/${a.id}`] = a.allowlist;
144
+ if (a.enabled === false) {
145
+ disabled.add(`${a.station}/${a.id}`);
146
+ continue;
147
+ }
141
148
  const cur = byStation.get(a.station);
142
149
  if (cur) cur.push(a);
143
150
  else byStation.set(a.station, [a]);
@@ -145,6 +152,7 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
145
152
  }
146
153
  setAgentMap(map, names);
147
154
  setAllowlistMap(allow);
155
+ setDisabledAccounts(disabled);
148
156
 
149
157
  const active = new Map<StationName, number>();
150
158
  const changed: StationName[] = [];
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.96"
2
+ "version": "0.1.0-beta.98"
3
3
  }