@stage-labs/metro 0.1.0-beta.131 → 0.1.0-beta.132
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/claude.js +3 -3
- package/dist/cli.js +3 -5
- package/dist/local.js +14 -17
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +14 -9
- package/runtime/node_modules/@metro-labs/daemon/src/agents/admin.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +2 -35
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +24 -23
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +36 -7
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +4 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -4
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/seed.ts +0 -33
package/dist/claude.js
CHANGED
|
@@ -82,7 +82,7 @@ export function agentKey(agents, wanted) {
|
|
|
82
82
|
catch {
|
|
83
83
|
return {
|
|
84
84
|
skip: wanted === undefined || wanted === ''
|
|
85
|
-
? '
|
|
85
|
+
? 'this box has no agent yet (or several old ones), so Claude Code talks to Anthropic directly for now'
|
|
86
86
|
: `no agent named '${wanted}' lives here, so Claude Code talks to Anthropic directly`,
|
|
87
87
|
};
|
|
88
88
|
}
|
|
@@ -102,7 +102,7 @@ async function verdict() {
|
|
|
102
102
|
const conflicts = settingsConflicts(settingsFiles());
|
|
103
103
|
if (conflicts.length > 0)
|
|
104
104
|
return { skip: `a settings file pins the provider (${conflicts.join(', ')}), so Claude Code keeps it` };
|
|
105
|
-
const picked = agentKey(localAgentList(),
|
|
105
|
+
const picked = agentKey(localAgentList(), undefined);
|
|
106
106
|
if ('skip' in picked)
|
|
107
107
|
return picked;
|
|
108
108
|
if (await daemonServing())
|
|
@@ -128,7 +128,7 @@ export function runClaude(args, env) {
|
|
|
128
128
|
async function servedKey(decision) {
|
|
129
129
|
if ('key' in decision)
|
|
130
130
|
return decision.key;
|
|
131
|
-
const picked = agentKey(localAgentList(),
|
|
131
|
+
const picked = agentKey(localAgentList(), undefined);
|
|
132
132
|
if ('skip' in picked)
|
|
133
133
|
return null;
|
|
134
134
|
return (await daemonServing()) ? picked.key : null;
|
package/dist/cli.js
CHANGED
|
@@ -25,11 +25,9 @@ const USAGE = `metro — run your agent on this machine
|
|
|
25
25
|
metro stop stop metro on this machine, metro serve included
|
|
26
26
|
metro tail <agent-id>
|
|
27
27
|
follow this machine's inbound events, one JSON line each
|
|
28
|
-
metro whoami
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
print the mcpServers block of the agent's connectors, served through the
|
|
32
|
-
daemon's own relay (name the agent if several live here)
|
|
28
|
+
metro whoami print the agent this machine runs
|
|
29
|
+
metro mcp print the mcpServers block of the agent's connectors, served through the
|
|
30
|
+
daemon's own relay
|
|
33
31
|
metro plugin set up the Claude Code plugin (connector servers + /metro:refresh)
|
|
34
32
|
metro claude [args...]
|
|
35
33
|
open Claude Code with the metro channel; every argument is passed through
|
package/dist/local.js
CHANGED
|
@@ -8,20 +8,24 @@ export function agentsDir() {
|
|
|
8
8
|
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
9
9
|
return explicit !== undefined && explicit !== '' ? explicit : join(homedir(), '.metro', 'agents');
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
function agentFiles(dir) {
|
|
12
|
+
const fixed = join(dir, 'agent.json');
|
|
13
|
+
if (existsSync(fixed))
|
|
14
|
+
return [fixed];
|
|
12
15
|
if (!existsSync(dir))
|
|
13
16
|
return [];
|
|
17
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
18
|
+
.filter((entry) => entry.isDirectory())
|
|
19
|
+
.map((entry) => join(dir, entry.name, 'agent.json'))
|
|
20
|
+
.filter((path) => existsSync(path));
|
|
21
|
+
}
|
|
22
|
+
export function localAgents(dir = agentsDir()) {
|
|
14
23
|
const out = [];
|
|
15
|
-
for (const
|
|
16
|
-
if (!entry.isDirectory())
|
|
17
|
-
continue;
|
|
18
|
-
const path = join(dir, entry.name, 'agent.json');
|
|
19
|
-
if (!existsSync(path))
|
|
20
|
-
continue;
|
|
24
|
+
for (const path of agentFiles(dir)) {
|
|
21
25
|
try {
|
|
22
26
|
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
-
if (typeof file.id === 'string' && typeof file.
|
|
24
|
-
out.push({ id: file.id, name: file.name, key: file.key });
|
|
27
|
+
if (typeof file.id === 'string' && typeof file.key === 'string')
|
|
28
|
+
out.push({ id: file.id, name: typeof file.name === 'string' ? file.name : 'agent', key: file.key });
|
|
25
29
|
}
|
|
26
30
|
catch {
|
|
27
31
|
continue;
|
|
@@ -30,15 +34,8 @@ export function localAgents(dir = agentsDir()) {
|
|
|
30
34
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
31
35
|
}
|
|
32
36
|
export function localStations(dir = agentsDir()) {
|
|
33
|
-
if (!existsSync(dir))
|
|
34
|
-
return [];
|
|
35
37
|
const out = new Set();
|
|
36
|
-
for (const
|
|
37
|
-
if (!entry.isDirectory())
|
|
38
|
-
continue;
|
|
39
|
-
const path = join(dir, entry.name, 'agent.json');
|
|
40
|
-
if (!existsSync(path))
|
|
41
|
-
continue;
|
|
38
|
+
for (const path of agentFiles(dir)) {
|
|
42
39
|
try {
|
|
43
40
|
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
44
41
|
if (!Array.isArray(file.stations))
|
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.132",
|
|
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.132",
|
|
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, the standing rules loaded at each session start, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -15,18 +15,23 @@ function agentsDir() {
|
|
|
15
15
|
return explicit ? explicit : join(homedir(), '.metro', 'agents');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function
|
|
19
|
-
const
|
|
18
|
+
function agentFiles(dir) {
|
|
19
|
+
const fixed = join(dir, 'agent.json');
|
|
20
|
+
if (existsSync(fixed)) return [fixed];
|
|
20
21
|
if (!existsSync(dir)) return [];
|
|
22
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
23
|
+
.filter((entry) => entry.isDirectory())
|
|
24
|
+
.map((entry) => join(dir, entry.name, 'agent.json'))
|
|
25
|
+
.filter((path) => existsSync(path));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function localAgents() {
|
|
21
29
|
const out = [];
|
|
22
|
-
for (const
|
|
23
|
-
if (!entry.isDirectory()) continue;
|
|
24
|
-
const path = join(dir, entry.name, 'agent.json');
|
|
25
|
-
if (!existsSync(path)) continue;
|
|
30
|
+
for (const path of agentFiles(agentsDir())) {
|
|
26
31
|
try {
|
|
27
32
|
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
-
if (typeof file.id === 'string' && typeof file.
|
|
29
|
-
out.push({ id: file.id, name: file.name, key: file.key });
|
|
33
|
+
if (typeof file.id === 'string' && typeof file.key === 'string')
|
|
34
|
+
out.push({ id: file.id, name: typeof file.name === 'string' ? file.name : 'agent', key: file.key });
|
|
30
35
|
} catch {
|
|
31
36
|
continue;
|
|
32
37
|
}
|
|
@@ -45,7 +50,7 @@ function pickAgent() {
|
|
|
45
50
|
if (agents.length === 1) return agents[0];
|
|
46
51
|
throw new Error(
|
|
47
52
|
agents.length === 0
|
|
48
|
-
? 'no agent on this machine yet — start metro serve
|
|
53
|
+
? 'no agent on this machine yet — start metro serve, it creates one at boot'
|
|
49
54
|
: `several agents on this machine — set METRO_AGENT to one of: ${agents.map((a) => a.name).join(', ')}`,
|
|
50
55
|
);
|
|
51
56
|
}
|
|
@@ -6,20 +6,20 @@ export class AgentAdminError extends ApiError {}
|
|
|
6
6
|
|
|
7
7
|
export interface AgentSummary {
|
|
8
8
|
id: string;
|
|
9
|
-
name: string;
|
|
9
|
+
name: string | null;
|
|
10
10
|
owned: boolean;
|
|
11
11
|
key: string | null;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface CreatedAgent {
|
|
15
15
|
id: string;
|
|
16
|
-
name: string;
|
|
16
|
+
name: string | null;
|
|
17
17
|
key: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface OwnedAgent {
|
|
21
21
|
id: string;
|
|
22
|
-
name: string;
|
|
22
|
+
name: string | null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export type DeletedAgent = OwnedAgent;
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
apiSession,
|
|
8
8
|
projectParam,
|
|
9
9
|
cors,
|
|
10
|
-
readJsonBody,
|
|
11
10
|
sendJson,
|
|
12
11
|
type ApiSession,
|
|
13
12
|
} from '@metro-labs/http/api-http';
|
|
@@ -19,7 +18,6 @@ import {
|
|
|
19
18
|
} from './account-routes.js';
|
|
20
19
|
import {
|
|
21
20
|
type AgentSummary,
|
|
22
|
-
type CreatedAgent,
|
|
23
21
|
type DeletedAgent,
|
|
24
22
|
type ResetAgentKey,
|
|
25
23
|
} from './admin.js';
|
|
@@ -29,11 +27,6 @@ const SERVER_NAME = 'metro';
|
|
|
29
27
|
|
|
30
28
|
export interface AgentApiDeps extends AccountApiDeps {
|
|
31
29
|
listAgents: (subject: string, project: string) => Promise<AgentSummary[]>;
|
|
32
|
-
createAgent: (
|
|
33
|
-
subject: string,
|
|
34
|
-
project: string,
|
|
35
|
-
name: string,
|
|
36
|
-
) => Promise<CreatedAgent>;
|
|
37
30
|
deleteAgent: (
|
|
38
31
|
subject: string,
|
|
39
32
|
id: string,
|
|
@@ -166,31 +159,6 @@ async function handleList(
|
|
|
166
159
|
sendJson(req, res, 200, { ...base, accounts, unavailable });
|
|
167
160
|
}
|
|
168
161
|
|
|
169
|
-
async function handleCreate(
|
|
170
|
-
req: IncomingMessage,
|
|
171
|
-
res: ServerResponse,
|
|
172
|
-
deps: AgentApiDeps,
|
|
173
|
-
session: ApiSession,
|
|
174
|
-
): Promise<void> {
|
|
175
|
-
const body = await readJsonBody(req);
|
|
176
|
-
const name = (body as { name?: unknown }).name;
|
|
177
|
-
const project = projectParam(req);
|
|
178
|
-
if (project === null) {
|
|
179
|
-
sendJson(req, res, 400, { error: 'a project is required' });
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
const created = await deps.createAgent(session.subject, project, name as string);
|
|
183
|
-
log.info(
|
|
184
|
-
{ agent: created.name, id: created.id, owner: session.subject },
|
|
185
|
-
'agent-api: created agent',
|
|
186
|
-
);
|
|
187
|
-
sendJson(req, res, 201, {
|
|
188
|
-
id: created.id,
|
|
189
|
-
name: created.name,
|
|
190
|
-
...credentials(created.key),
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
|
|
194
162
|
async function handleResetKey(
|
|
195
163
|
req: IncomingMessage,
|
|
196
164
|
res: ServerResponse,
|
|
@@ -239,15 +207,14 @@ async function routeAgent(
|
|
|
239
207
|
if (tgt.kind === 'key') await handleResetKey(req, res, deps, session, tgt.id);
|
|
240
208
|
else if (tgt.kind === 'agent')
|
|
241
209
|
await handleDelete(req, res, deps, session, tgt.id);
|
|
242
|
-
else
|
|
243
|
-
else await handleCreate(req, res, deps, session);
|
|
210
|
+
else await handleList(req, res, deps, session);
|
|
244
211
|
} catch (err) {
|
|
245
212
|
apiFailure(req, res, err);
|
|
246
213
|
}
|
|
247
214
|
}
|
|
248
215
|
|
|
249
216
|
const ALLOWED: Record<AgentTarget['kind'], string[]> = {
|
|
250
|
-
collection: ['GET'
|
|
217
|
+
collection: ['GET'],
|
|
251
218
|
agent: ['DELETE'],
|
|
252
219
|
key: ['POST'],
|
|
253
220
|
};
|
|
@@ -69,9 +69,9 @@ function agentOf(raw: unknown): AgentBundle['agent'] {
|
|
|
69
69
|
if (!isRecord(raw)) throw bad('not a v1 agent bundle');
|
|
70
70
|
const { id, name, key, stations } = raw;
|
|
71
71
|
if (typeof id !== 'string' || parseId(id) === null) throw bad('agent id is not an id');
|
|
72
|
-
if (typeof
|
|
72
|
+
if (typeof key !== 'string' || key === '') throw bad('agent has no key');
|
|
73
73
|
if (!Array.isArray(stations)) throw bad('agent has no station list');
|
|
74
|
-
return { id, name, key, stations: stations.map(stationOf) };
|
|
74
|
+
return { id, name: typeof name === 'string' ? name : '', key, stations: stations.map(stationOf) };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function parseBundle(raw: unknown): AgentBundle {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, rmdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
4
|
import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from './admin.js';
|
|
15
15
|
import type { AccountRef } from './account-attach.js';
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
agentFilePath,
|
|
18
18
|
AgentFileError,
|
|
19
19
|
agentsDir,
|
|
20
20
|
listAgentFiles,
|
|
@@ -94,7 +94,7 @@ export async function localListAgents(
|
|
|
94
94
|
return Promise.resolve(
|
|
95
95
|
storedAgents(dir)
|
|
96
96
|
.map(({ file }) => ({ id: file.id, name: file.name, owned: true, key: file.key }))
|
|
97
|
-
.sort((a, b) => a.
|
|
97
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -109,30 +109,35 @@ function freshId(taken: Set<string>): string {
|
|
|
109
109
|
export async function localCreateAgent(
|
|
110
110
|
subject: string,
|
|
111
111
|
project: string,
|
|
112
|
-
rawName
|
|
112
|
+
rawName?: string,
|
|
113
113
|
dir = agentsDir(),
|
|
114
114
|
): Promise<CreatedAgent> {
|
|
115
115
|
if (project !== LOCAL_PROJECT_ID || !isOwner(subject, dir))
|
|
116
116
|
throw new AgentAdminError('no such project', 404);
|
|
117
|
-
const name = normalizeAgentName(rawName);
|
|
118
|
-
const folder = join(dir, name);
|
|
119
|
-
if (existsSync(join(folder, AGENT_FILE)))
|
|
120
|
-
throw new AgentAdminError(
|
|
121
|
-
`an agent named '${name}' already exists on this machine`,
|
|
122
|
-
409,
|
|
123
|
-
);
|
|
117
|
+
const name = rawName === undefined ? null : normalizeAgentName(rawName);
|
|
124
118
|
const existing = storedAgents(dir);
|
|
125
|
-
|
|
119
|
+
if (existing.length > 0) throw new AgentAdminError('this box already has its agent', 409);
|
|
120
|
+
const id = freshId(new Set());
|
|
126
121
|
const key = newApiKey();
|
|
127
|
-
ensureSecureDir(
|
|
122
|
+
ensureSecureDir(dir);
|
|
128
123
|
save({
|
|
129
|
-
path:
|
|
124
|
+
path: agentFilePath(dir),
|
|
130
125
|
file: { version: 1, id, name, key, owner: localOwner(dir), stations: [], connectors: [] },
|
|
131
126
|
});
|
|
132
127
|
registerKey(key, id);
|
|
133
128
|
return Promise.resolve({ id, name, key });
|
|
134
129
|
}
|
|
135
130
|
|
|
131
|
+
export type Ensured = 'created' | 'present' | 'no-owner';
|
|
132
|
+
|
|
133
|
+
export async function ensureLocalAgent(dir = agentsDir()): Promise<Ensured> {
|
|
134
|
+
if (storedAgents(dir).length > 0) return 'present';
|
|
135
|
+
const owner = localOwner(dir);
|
|
136
|
+
if (owner === null) return 'no-owner';
|
|
137
|
+
await localCreateAgent(owner, LOCAL_PROJECT_ID, undefined, dir);
|
|
138
|
+
return 'created';
|
|
139
|
+
}
|
|
140
|
+
|
|
136
141
|
export async function localResetAgentKey(
|
|
137
142
|
subject: string,
|
|
138
143
|
id: string,
|
|
@@ -155,7 +160,7 @@ export async function localDeleteAgent(
|
|
|
155
160
|
const attached = stored.file.stations.length;
|
|
156
161
|
if (attached > 0)
|
|
157
162
|
throw new AgentAdminError(
|
|
158
|
-
`agent
|
|
163
|
+
`the agent still has ${String(attached)} station account(s) attached — detach them first`,
|
|
159
164
|
409,
|
|
160
165
|
);
|
|
161
166
|
rmSync(stored.path);
|
|
@@ -196,14 +201,10 @@ interface ImportTarget {
|
|
|
196
201
|
function importTarget(dir: string, agent: LoadedAgent & { key: string }): ImportTarget {
|
|
197
202
|
const existing = storedAgents(dir);
|
|
198
203
|
const same = existing.find((s) => s.file.id === agent.id);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
throw new AgentAdminError(
|
|
202
|
-
`an agent named '${agent.name}' already exists on this machine`,
|
|
203
|
-
409,
|
|
204
|
-
);
|
|
204
|
+
if (same === undefined && existing.length > 0)
|
|
205
|
+
throw new AgentAdminError('this box already has its agent; import into it rather than bringing another', 409);
|
|
205
206
|
assertUnclaimed(existing.filter((s) => s !== same), agent);
|
|
206
|
-
return { path: same?.path ??
|
|
207
|
+
return { path: same?.path ?? agentFilePath(dir), previous: same?.file };
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
export type ImportMode = 'append' | 'overwrite';
|
|
@@ -256,7 +257,7 @@ function fileFor(
|
|
|
256
257
|
JSON.stringify({
|
|
257
258
|
version: 1,
|
|
258
259
|
id: agent.id,
|
|
259
|
-
name: previous?.name ??
|
|
260
|
+
name: previous?.name ?? null,
|
|
260
261
|
key: agent.key,
|
|
261
262
|
owner,
|
|
262
263
|
stations: mergedStations(agent, previous, mode),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, renameSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { log } from '@metro-labs/core/log';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { AGENT_NAME_RE, ID_RE } from '@metro-labs/core/ids';
|
|
@@ -17,13 +18,15 @@ export class AgentFileError extends Error {}
|
|
|
17
18
|
export interface AgentFile {
|
|
18
19
|
version: 1;
|
|
19
20
|
id: string;
|
|
20
|
-
name: string;
|
|
21
|
+
name: string | null;
|
|
21
22
|
key: string | null;
|
|
22
23
|
owner: string | null;
|
|
23
24
|
stations: LoadedAccount[];
|
|
24
25
|
connectors: string[];
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export const agentFilePath = (dir = agentsDir()): string => join(dir, AGENT_FILE);
|
|
29
|
+
|
|
27
30
|
export function agentsDir(): string {
|
|
28
31
|
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
29
32
|
return explicit !== undefined && explicit !== ''
|
|
@@ -73,6 +76,12 @@ function optionalMatch(
|
|
|
73
76
|
const connectorsOf = (raw: unknown): string[] =>
|
|
74
77
|
Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && ID_RE.test(id)) : [];
|
|
75
78
|
|
|
79
|
+
function nameOf(raw: unknown, path: string): string | null {
|
|
80
|
+
if (raw === undefined || raw === null) return null;
|
|
81
|
+
if (typeof raw !== 'string' || !AGENT_NAME_RE.test(raw)) fail(path, 'name is not a valid agent name');
|
|
82
|
+
return raw;
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
77
86
|
let parsed: unknown;
|
|
78
87
|
try {
|
|
@@ -85,8 +94,7 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
|
85
94
|
const { id, name, stations } = parsed;
|
|
86
95
|
if (typeof id !== 'string' || !ID_RE.test(id))
|
|
87
96
|
fail(path, 'id is not an 11-character id');
|
|
88
|
-
|
|
89
|
-
fail(path, 'name is not a valid agent name');
|
|
97
|
+
const label = nameOf(name, path);
|
|
90
98
|
const key = optionalMatch(parsed.key, KEY_RE, path, 'key is not an agent key');
|
|
91
99
|
const owner = optionalMatch(
|
|
92
100
|
parsed.owner,
|
|
@@ -98,7 +106,7 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
|
98
106
|
return {
|
|
99
107
|
version: 1,
|
|
100
108
|
id,
|
|
101
|
-
name,
|
|
109
|
+
name: label,
|
|
102
110
|
key,
|
|
103
111
|
owner,
|
|
104
112
|
stations: stations.map((s, i) => stationOf(s, path, i)),
|
|
@@ -116,7 +124,7 @@ export function readAgentFile(path: string): AgentFile {
|
|
|
116
124
|
return parseAgentFile(raw, path);
|
|
117
125
|
}
|
|
118
126
|
|
|
119
|
-
|
|
127
|
+
function legacyAgentFiles(dir: string): string[] {
|
|
120
128
|
if (!existsSync(dir)) return [];
|
|
121
129
|
return readdirSync(dir, { withFileTypes: true })
|
|
122
130
|
.filter((entry) => entry.isDirectory())
|
|
@@ -125,6 +133,27 @@ export function listAgentFiles(dir = agentsDir()): string[] {
|
|
|
125
133
|
.sort();
|
|
126
134
|
}
|
|
127
135
|
|
|
136
|
+
export function listAgentFiles(dir = agentsDir()): string[] {
|
|
137
|
+
const fixed = agentFilePath(dir);
|
|
138
|
+
return existsSync(fixed) ? [fixed] : legacyAgentFiles(dir);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function migrateAgentLayout(dir = agentsDir()): 'moved' | 'kept' | 'none' {
|
|
142
|
+
const fixed = agentFilePath(dir);
|
|
143
|
+
if (existsSync(fixed)) return 'kept';
|
|
144
|
+
const [first, ...rest] = legacyAgentFiles(dir);
|
|
145
|
+
if (first === undefined) return 'none';
|
|
146
|
+
renameSync(first, fixed);
|
|
147
|
+
try {
|
|
148
|
+
rmdirSync(join(first, '..'));
|
|
149
|
+
} catch {
|
|
150
|
+
log.warn({ folder: join(first, '..') }, 'agent layout: the old agent folder is not empty and was left in place');
|
|
151
|
+
}
|
|
152
|
+
for (const extra of rest) log.warn({ path: extra }, 'agent layout: a second agent file is ignored; a box holds one agent');
|
|
153
|
+
log.info({ from: first, to: fixed }, 'agent layout: moved the agent file to its fixed place');
|
|
154
|
+
return 'moved';
|
|
155
|
+
}
|
|
156
|
+
|
|
128
157
|
function assertUnique(agents: AgentFile[], paths: string[]): void {
|
|
129
158
|
const ids = new Map<string, string>();
|
|
130
159
|
const keys = new Map<string, string>();
|
|
@@ -146,7 +175,7 @@ export function loadFileAgents(dir = agentsDir()): LoadedAgent[] {
|
|
|
146
175
|
assertUnique(agents, paths);
|
|
147
176
|
return agents.map((agent) => ({
|
|
148
177
|
id: agent.id,
|
|
149
|
-
name: agent.name,
|
|
178
|
+
name: agent.name ?? agent.id,
|
|
150
179
|
key: agent.key,
|
|
151
180
|
accounts: agent.stations,
|
|
152
181
|
}));
|
|
@@ -26,8 +26,8 @@ import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
|
26
26
|
import { unwatchSession, watchSession } from '../claude/session.js';
|
|
27
27
|
import { tryClaudeSetup } from '../claude/setup.js';
|
|
28
28
|
import { applyLocalOwner } from './local-owner.js';
|
|
29
|
-
import { localOwner } from '../agents/file-admin.js';
|
|
30
|
-
import {
|
|
29
|
+
import { ensureLocalAgent, localOwner } from '../agents/file-admin.js';
|
|
30
|
+
import { migrateAgentLayout } from '../agents/files.js';
|
|
31
31
|
import { ensureStationDeps } from '../stations/runtime-deps.js';
|
|
32
32
|
import { localSessionApis } from '../routes/local-mode.js';
|
|
33
33
|
import type { SessionApis } from '../routes/session-apis.js';
|
|
@@ -135,7 +135,8 @@ function startConnectors(): void {
|
|
|
135
135
|
|
|
136
136
|
async function main(): Promise<void> {
|
|
137
137
|
applyLocalOwner();
|
|
138
|
-
|
|
138
|
+
migrateAgentLayout();
|
|
139
|
+
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
139
140
|
await materializeFrom(fileSource, { allowEmpty: true });
|
|
140
141
|
supervisor.start();
|
|
141
142
|
const metroMcp = await createMetroMcp();
|
|
@@ -33,7 +33,6 @@ import {
|
|
|
33
33
|
assertLocalOwner,
|
|
34
34
|
LOCAL_PROJECT_ID,
|
|
35
35
|
localAttachAccount,
|
|
36
|
-
localCreateAgent,
|
|
37
36
|
localDeleteAgent,
|
|
38
37
|
localDetachAccount,
|
|
39
38
|
localSetAllowlist,
|
|
@@ -87,7 +86,6 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
87
86
|
return {
|
|
88
87
|
attachSessions: attachSessions(deps),
|
|
89
88
|
listAgents: localListAgents,
|
|
90
|
-
createAgent: localCreateAgent,
|
|
91
89
|
deleteAgent: localDeleteAgent,
|
|
92
90
|
resetKey: async (subject, id) => {
|
|
93
91
|
const reset = await localResetAgentKey(subject, id);
|
|
@@ -158,10 +156,10 @@ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
|
|
|
158
156
|
const file = readLocalAgentFile(agentId);
|
|
159
157
|
const bundle: AgentBundle = {
|
|
160
158
|
version: 1,
|
|
161
|
-
agent: { id: file.id, name: file.name, key: file.key ?? '', stations: file.stations },
|
|
159
|
+
agent: { id: file.id, name: file.name ?? '', key: file.key ?? '', stations: file.stations },
|
|
162
160
|
connectors: readLocalConnectors().map((c) => ({ id: c.id, name: c.name, url: c.url, transport: c.transport, config: { ...c.config } })),
|
|
163
161
|
};
|
|
164
|
-
if (bundle.agent.key === '') throw new ApiError(`agent
|
|
162
|
+
if (bundle.agent.key === '') throw new ApiError(`agent ${agent.id} has no key to bundle`, 400);
|
|
165
163
|
return bundle;
|
|
166
164
|
},
|
|
167
165
|
restore: async (subject, bundle, mode) => {
|
package/runtime/runtime.json
CHANGED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
-
import { agentsDir } from './files.js';
|
|
5
|
-
import { LOCAL_PROJECT_ID, localCreateAgent, localOwner, storedAgents } from './file-admin.js';
|
|
6
|
-
|
|
7
|
-
export const SEED_FILE = '.agent';
|
|
8
|
-
|
|
9
|
-
export type Seeded = 'created' | 'present' | 'none' | 'failed';
|
|
10
|
-
|
|
11
|
-
export async function seedAgent(dir = agentsDir()): Promise<Seeded> {
|
|
12
|
-
const path = join(dir, SEED_FILE);
|
|
13
|
-
if (!existsSync(path)) return 'none';
|
|
14
|
-
const name = readFileSync(path, 'utf8').trim();
|
|
15
|
-
const owner = localOwner(dir);
|
|
16
|
-
if (owner === null) {
|
|
17
|
-
log.warn({ name }, 'agent seed: no owner yet, leaving the seed for the next boot');
|
|
18
|
-
return 'failed';
|
|
19
|
-
}
|
|
20
|
-
if (storedAgents(dir).length > 0) {
|
|
21
|
-
rmSync(path, { force: true });
|
|
22
|
-
return 'present';
|
|
23
|
-
}
|
|
24
|
-
try {
|
|
25
|
-
const made = await localCreateAgent(owner, LOCAL_PROJECT_ID, name, dir);
|
|
26
|
-
rmSync(path, { force: true });
|
|
27
|
-
log.info({ id: made.id, name: made.name }, 'agent seed: agent created from the launch name');
|
|
28
|
-
return 'created';
|
|
29
|
-
} catch (err) {
|
|
30
|
-
log.warn({ name, err: errMsg(err) }, 'agent seed: could not create the agent');
|
|
31
|
-
return 'failed';
|
|
32
|
-
}
|
|
33
|
-
}
|