@stage-labs/metro 0.1.0-beta.194 → 0.1.0-beta.195
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-lookups.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +18 -64
- package/runtime/node_modules/@metro-labs/daemon/src/agents/sender-cards.ts +74 -0
- package/runtime/runtime.json +1 -1
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.195",
|
|
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": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.195",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { StationName } from '@metro-labs/core/station-names';
|
|
3
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
import { bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
5
|
+
import { stationByName } from '../stations/registry.js';
|
|
6
|
+
import type { AccountRoute } from './account-routes.js';
|
|
7
|
+
import { handleSenderCards, type SenderCardDeps } from './sender-cards.js';
|
|
8
|
+
|
|
9
|
+
interface Target {
|
|
10
|
+
station: StationName;
|
|
11
|
+
accountId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LookupDeps extends SenderCardDeps {
|
|
15
|
+
resolveSender: (station: StationName, accountId: string, query: string) => Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Lookup = (req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target) => Promise<void>;
|
|
19
|
+
|
|
20
|
+
function lookupQuery(req: IncomingMessage): string {
|
|
21
|
+
const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
22
|
+
return (params.get('q') ?? '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function handleResolve(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
|
|
26
|
+
if (stationByName(target.station)?.resolvesSenders !== true)
|
|
27
|
+
throw new ApiError(`metro cannot look a sender up on ${target.station}`, 400);
|
|
28
|
+
const query = lookupQuery(req);
|
|
29
|
+
if (query === '') throw new ApiError('a number to look up is required', 400);
|
|
30
|
+
sendJson(req, res, 200, await deps.resolveSender(target.station, target.accountId, query));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function handleName(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
|
|
34
|
+
if (stationByName(target.station)?.claimsName !== true)
|
|
35
|
+
throw new ApiError(`${target.station} accounts have no name to claim`, 400);
|
|
36
|
+
if (req.method === 'GET') {
|
|
37
|
+
sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const label = bodyField(await readJsonBody(req), 'label');
|
|
41
|
+
if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
|
|
42
|
+
sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function handleRecent(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
|
|
46
|
+
sendJson(req, res, 200, { senders: deps.recentSenders(target.station, target.accountId) });
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const LOOKUPS: Partial<Record<AccountRoute['kind'], Lookup>> = {
|
|
51
|
+
profiles: handleSenderCards,
|
|
52
|
+
resolve: handleResolve,
|
|
53
|
+
name: handleName,
|
|
54
|
+
senders: handleRecent,
|
|
55
|
+
};
|
|
@@ -12,6 +12,7 @@ export type AccountRoute =
|
|
|
12
12
|
| { kind: 'resolve'; station: StationName; accountId: string }
|
|
13
13
|
| { kind: 'enabled'; station: StationName; accountId: string }
|
|
14
14
|
| { kind: 'policy'; station: StationName; accountId: string }
|
|
15
|
+
| { kind: 'profiles'; station: StationName; accountId: string }
|
|
15
16
|
| { kind: 'name'; station: StationName; accountId: string };
|
|
16
17
|
|
|
17
18
|
const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
@@ -24,10 +25,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
24
25
|
resolve: ['GET'],
|
|
25
26
|
enabled: ['PUT'],
|
|
26
27
|
policy: ['PUT'],
|
|
28
|
+
profiles: ['GET'],
|
|
27
29
|
name: ['GET', 'POST'],
|
|
28
30
|
};
|
|
29
31
|
|
|
30
|
-
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy'] as const;
|
|
32
|
+
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles'] as const;
|
|
31
33
|
|
|
32
34
|
type SubRoute = (typeof SUB_ROUTES)[number];
|
|
33
35
|
|
|
@@ -5,15 +5,11 @@ import { approversForAccount } from './map.js';
|
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import type { RecentSender } from './senders.js';
|
|
7
7
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
8
|
-
import {
|
|
9
|
-
apiFailure,
|
|
10
|
-
bodyField,
|
|
11
|
-
readJsonBody,
|
|
12
|
-
sendJson,
|
|
13
|
-
} from '@metro-labs/http/api-http';
|
|
8
|
+
import { apiFailure, bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
14
9
|
import { type AccountRef } from './account-attach.js';
|
|
15
10
|
import { type AccountRoute } from './account-routes.js';
|
|
16
11
|
import { handlePolicy, type SetPolicy } from './policy-route.js';
|
|
12
|
+
import { LOOKUPS } from './account-lookups.js';
|
|
17
13
|
import { stationByName } from '../stations/registry.js';
|
|
18
14
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
19
15
|
import {
|
|
@@ -92,46 +88,7 @@ export interface AccountApiDeps {
|
|
|
92
88
|
) => Promise<boolean>;
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
export const ATTACHABLE: string[] = [
|
|
96
|
-
...ATTACHABLE_STATIONS,
|
|
97
|
-
...INTERACTIVE_STATIONS,
|
|
98
|
-
];
|
|
99
|
-
|
|
100
|
-
function lookupQuery(req: IncomingMessage): string {
|
|
101
|
-
const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
102
|
-
return (params.get('q') ?? '').trim();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function handleResolve(
|
|
106
|
-
req: IncomingMessage,
|
|
107
|
-
res: ServerResponse,
|
|
108
|
-
deps: AccountApiDeps,
|
|
109
|
-
target: { station: StationName; accountId: string },
|
|
110
|
-
): Promise<void> {
|
|
111
|
-
if (stationByName(target.station)?.resolvesSenders !== true)
|
|
112
|
-
throw new ApiError(`metro cannot look a sender up on ${target.station}`, 400);
|
|
113
|
-
const query = lookupQuery(req);
|
|
114
|
-
if (query === '') throw new ApiError('a number to look up is required', 400);
|
|
115
|
-
const found = await deps.resolveSender(target.station, target.accountId, query);
|
|
116
|
-
sendJson(req, res, 200, found);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function handleName(
|
|
120
|
-
req: IncomingMessage,
|
|
121
|
-
res: ServerResponse,
|
|
122
|
-
deps: AccountApiDeps,
|
|
123
|
-
target: { station: StationName; accountId: string },
|
|
124
|
-
): Promise<void> {
|
|
125
|
-
if (stationByName(target.station)?.claimsName !== true)
|
|
126
|
-
throw new ApiError(`${target.station} accounts have no name to claim`, 400);
|
|
127
|
-
if (req.method === 'GET') {
|
|
128
|
-
sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
const label = bodyField(await readJsonBody(req), 'label');
|
|
132
|
-
if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
|
|
133
|
-
sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
|
|
134
|
-
}
|
|
91
|
+
export const ATTACHABLE: string[] = [...ATTACHABLE_STATIONS, ...INTERACTIVE_STATIONS];
|
|
135
92
|
|
|
136
93
|
async function activate(
|
|
137
94
|
deps: AccountApiDeps,
|
|
@@ -353,6 +310,18 @@ async function handleStep(
|
|
|
353
310
|
sendJson(req, res, 200, view);
|
|
354
311
|
}
|
|
355
312
|
|
|
313
|
+
async function dispatchAttach(
|
|
314
|
+
req: IncomingMessage,
|
|
315
|
+
res: ServerResponse,
|
|
316
|
+
deps: AccountApiDeps,
|
|
317
|
+
agentId: string,
|
|
318
|
+
route: Extract<AccountRoute, { kind: 'start' | 'session' | 'step' }>,
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
if (route.kind === 'start') return handleStart(req, res, deps, agentId);
|
|
321
|
+
if (route.kind === 'session') return handleSession(req, res, deps, { agentId }, route.attachId);
|
|
322
|
+
return handleStep(req, res, deps, { agentId }, route.attachId);
|
|
323
|
+
}
|
|
324
|
+
|
|
356
325
|
async function dispatchRoute(
|
|
357
326
|
req: IncomingMessage,
|
|
358
327
|
res: ServerResponse,
|
|
@@ -360,27 +329,12 @@ async function dispatchRoute(
|
|
|
360
329
|
agentId: string,
|
|
361
330
|
route: AccountRoute,
|
|
362
331
|
): Promise<void> {
|
|
363
|
-
if (route.kind === 'start')
|
|
364
|
-
return handleStart(req, res, deps, agentId);
|
|
365
|
-
if (route.kind === 'session')
|
|
366
|
-
return handleSession(
|
|
367
|
-
req,
|
|
368
|
-
res,
|
|
369
|
-
deps,
|
|
370
|
-
{ agentId },
|
|
371
|
-
route.attachId,
|
|
372
|
-
);
|
|
373
|
-
if (route.kind === 'step')
|
|
374
|
-
return handleStep(req, res, deps, { agentId }, route.attachId);
|
|
332
|
+
if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route);
|
|
375
333
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
|
|
376
334
|
if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
|
|
377
335
|
if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
if (route.kind === 'senders') {
|
|
381
|
-
sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
336
|
+
const lookup = LOOKUPS[route.kind];
|
|
337
|
+
if (lookup !== undefined && 'station' in route) return lookup(req, res, deps, route);
|
|
384
338
|
return handleDetach(req, res, deps, agentId, route);
|
|
385
339
|
}
|
|
386
340
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { StationName } from '@metro-labs/core/station-names';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import { sendJson } from '@metro-labs/http/api-http';
|
|
5
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
+
import { stationByName } from '../stations/registry.js';
|
|
7
|
+
import { allowlistForAccount } from './map.js';
|
|
8
|
+
import type { RecentSender } from './senders.js';
|
|
9
|
+
|
|
10
|
+
export interface SenderCard {
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
handle?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SenderCardDeps {
|
|
17
|
+
recentSenders: (station: StationName, accountId: string) => RecentSender[];
|
|
18
|
+
accountCall: (station: StationName, action: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAX_LOOKUPS = 50;
|
|
22
|
+
const LOOKUP_MS = 8_000;
|
|
23
|
+
|
|
24
|
+
const filled = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
|
|
25
|
+
|
|
26
|
+
function cardOf(id: string, profile: unknown, seenName: string | undefined): SenderCard {
|
|
27
|
+
const p = isRecord(profile) ? profile : {};
|
|
28
|
+
const display = filled(p.display_name);
|
|
29
|
+
const handle = filled(p.name);
|
|
30
|
+
const name = display ?? seenName ?? handle;
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
...(name === undefined ? {} : { name }),
|
|
34
|
+
...(handle === undefined || handle === name ? {} : { handle }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function lookup(deps: SenderCardDeps, station: StationName, accountId: string, user: string): Promise<unknown> {
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
const late = new Promise<null>((resolve) => {
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
resolve(null);
|
|
43
|
+
}, LOOKUP_MS);
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
return await Promise.race([deps.accountCall(station, 'profile', { account: accountId, user }), late]);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.info({ station, account: accountId, err: errMsg(err) }, 'account-api: could not read the profile of a listed sender');
|
|
49
|
+
return null;
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function senderCards(deps: SenderCardDeps, station: StationName, accountId: string): Promise<SenderCard[]> {
|
|
56
|
+
const ids = (allowlistForAccount(station, accountId) ?? []).filter((id) => id !== '*' && !id.startsWith('@'));
|
|
57
|
+
const seen = new Map(deps.recentSenders(station, accountId).map((s) => [s.id.toLowerCase(), filled(s.name)]));
|
|
58
|
+
const reads = stationByName(station)?.readsProfiles === true;
|
|
59
|
+
return Promise.all(
|
|
60
|
+
ids.map(async (id, at) => {
|
|
61
|
+
const profile = reads && at < MAX_LOOKUPS ? await lookup(deps, station, accountId, id) : null;
|
|
62
|
+
return cardOf(id, profile, seen.get(id.toLowerCase()));
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function handleSenderCards(
|
|
68
|
+
req: IncomingMessage,
|
|
69
|
+
res: ServerResponse,
|
|
70
|
+
deps: SenderCardDeps,
|
|
71
|
+
target: { station: StationName; accountId: string },
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
sendJson(req, res, 200, { senders: await senderCards(deps, target.station, target.accountId) });
|
|
74
|
+
}
|
package/runtime/runtime.json
CHANGED