@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 +45 -5
- package/dist/runtime-install.js +4 -0
- package/package.json +1 -1
- package/runtime/marketplace/.claude-plugin/marketplace.json +12 -0
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +18 -0
- package/runtime/marketplace/plugin/.mcp.json +1 -0
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +122 -0
- package/runtime/marketplace/plugin/skills/refresh/SKILL.md +15 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +33 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +7 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +9 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +128 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +8 -0
- package/runtime/runtime.json +1 -1
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
|
|
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(
|
|
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');
|
package/dist/runtime-install.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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[] = [];
|
package/runtime/runtime.json
CHANGED