@stage-labs/metro 0.1.0-beta.85 → 0.1.0-beta.87
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/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +58 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +37 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +16 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/senders.ts +38 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +32 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +154 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +6 -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.87",
|
|
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,5 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { normalizeAllowlist } from './allowlist.js';
|
|
4
|
+
import type { RecentSender } from './senders.js';
|
|
3
5
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
6
|
import {
|
|
5
7
|
apiFailure,
|
|
@@ -63,13 +65,24 @@ export interface AccountApiDeps {
|
|
|
63
65
|
accountId: string,
|
|
64
66
|
) => Promise<AccountRef>;
|
|
65
67
|
syncStations: (station: StationName) => Promise<void>;
|
|
68
|
+
reloadAgents: () => Promise<void>;
|
|
69
|
+
setAllowlist: (
|
|
70
|
+
subject: string,
|
|
71
|
+
agentId: string,
|
|
72
|
+
station: StationName,
|
|
73
|
+
accountId: string,
|
|
74
|
+
allowlist: string[],
|
|
75
|
+
) => Promise<string[]>;
|
|
76
|
+
recentSenders: (station: StationName, accountId: string) => RecentSender[];
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
export type AccountRoute =
|
|
69
80
|
| { kind: 'start' }
|
|
70
81
|
| { kind: 'session'; attachId: string }
|
|
71
82
|
| { kind: 'step'; attachId: string }
|
|
72
|
-
| { kind: 'account'; station: StationName; accountId: string }
|
|
83
|
+
| { kind: 'account'; station: StationName; accountId: string }
|
|
84
|
+
| { kind: 'allowlist'; station: StationName; accountId: string }
|
|
85
|
+
| { kind: 'senders'; station: StationName; accountId: string };
|
|
73
86
|
|
|
74
87
|
export const ATTACHABLE: string[] = [
|
|
75
88
|
...ATTACHABLE_STATIONS,
|
|
@@ -81,6 +94,8 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
81
94
|
session: ['GET', 'DELETE'],
|
|
82
95
|
step: ['POST'],
|
|
83
96
|
account: ['DELETE'],
|
|
97
|
+
allowlist: ['PUT'],
|
|
98
|
+
senders: ['GET'],
|
|
84
99
|
};
|
|
85
100
|
|
|
86
101
|
function twoSegmentRoute(head: string, tail: string): AccountRoute | null {
|
|
@@ -100,10 +115,17 @@ export function accountRoute(rest: string[]): AccountRoute | null {
|
|
|
100
115
|
: ATTACH_ID_RE.test(head)
|
|
101
116
|
? { kind: 'session', attachId: head }
|
|
102
117
|
: null;
|
|
118
|
+
if (rest.length === 3 && tail !== undefined) return accountSubRoute(head, tail, rest[2]);
|
|
103
119
|
if (rest.length !== 2 || tail === undefined) return null;
|
|
104
120
|
return twoSegmentRoute(head, tail);
|
|
105
121
|
}
|
|
106
122
|
|
|
123
|
+
function accountSubRoute(head: string, tail: string, sub: string | undefined): AccountRoute | null {
|
|
124
|
+
if (!isStationName(head) || (sub !== 'allowlist' && sub !== 'senders')) return null;
|
|
125
|
+
const accountId = parseAccountId(tail);
|
|
126
|
+
return accountId === null ? null : { kind: sub, station: head, accountId };
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
export function accountRouteAllows(
|
|
108
130
|
route: AccountRoute,
|
|
109
131
|
method: string | undefined,
|
|
@@ -242,6 +264,36 @@ async function handleDetach(
|
|
|
242
264
|
});
|
|
243
265
|
}
|
|
244
266
|
|
|
267
|
+
async function applied(deps: AccountApiDeps): Promise<boolean> {
|
|
268
|
+
try {
|
|
269
|
+
await deps.reloadAgents();
|
|
270
|
+
return true;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log.warn({ err: errMsg(err) }, 'account-api: allowlist reload failed, the change lands at the next boot');
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function handleAllowlist(
|
|
278
|
+
req: IncomingMessage,
|
|
279
|
+
res: ServerResponse,
|
|
280
|
+
deps: AccountApiDeps,
|
|
281
|
+
session: ApiSession,
|
|
282
|
+
agentId: string,
|
|
283
|
+
target: { station: StationName; accountId: string },
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
const wanted = normalizeAllowlist(bodyField(await readJsonBody(req), 'allowlist'));
|
|
286
|
+
const allowlist = await deps.setAllowlist(session.subject, agentId, target.station, target.accountId, wanted);
|
|
287
|
+
log.info({ agentId, station: target.station, account: target.accountId, senders: allowlist.length }, 'account-api: allowlist set');
|
|
288
|
+
sendJson(req, res, 200, {
|
|
289
|
+
agentId,
|
|
290
|
+
station: target.station,
|
|
291
|
+
accountId: target.accountId,
|
|
292
|
+
allowlist,
|
|
293
|
+
activated: await applied(deps),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
245
297
|
async function handleSession(
|
|
246
298
|
req: IncomingMessage,
|
|
247
299
|
res: ServerResponse,
|
|
@@ -292,6 +344,11 @@ async function dispatchRoute(
|
|
|
292
344
|
);
|
|
293
345
|
if (route.kind === 'step')
|
|
294
346
|
return handleStep(req, res, deps, ownerOf(session, agentId), route.attachId);
|
|
347
|
+
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, session, agentId, route);
|
|
348
|
+
if (route.kind === 'senders') {
|
|
349
|
+
sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
295
352
|
return handleDetach(req, res, deps, session, agentId, route);
|
|
296
353
|
}
|
|
297
354
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
|
+
|
|
3
|
+
export const EVERYONE = '*';
|
|
4
|
+
const MAX_ENTRIES = 500;
|
|
5
|
+
const MAX_LENGTH = 200;
|
|
6
|
+
|
|
7
|
+
function hasControlChar(value: string): boolean {
|
|
8
|
+
for (const ch of value) {
|
|
9
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
10
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function senderId(entry: unknown): string {
|
|
16
|
+
if (typeof entry !== 'string') throw new ApiError('allowlist must be a list of sender ids', 400);
|
|
17
|
+
const value = entry.trim();
|
|
18
|
+
if (value.length > MAX_LENGTH || hasControlChar(value))
|
|
19
|
+
throw new ApiError(`a sender id is at most ${String(MAX_LENGTH)} plain characters`, 400);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeAllowlist(raw: unknown): string[] {
|
|
24
|
+
if (!Array.isArray(raw)) throw new ApiError('allowlist must be a list of sender ids', 400);
|
|
25
|
+
if (raw.length > MAX_ENTRIES) throw new ApiError(`allowlist holds at most ${String(MAX_ENTRIES)} senders`, 400);
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
const out: string[] = [];
|
|
28
|
+
for (const entry of raw) {
|
|
29
|
+
const value = senderId(entry);
|
|
30
|
+
if (value === EVERYONE) return [EVERYONE];
|
|
31
|
+
const key = value.toLowerCase();
|
|
32
|
+
if (key === '' || seen.has(key)) continue;
|
|
33
|
+
seen.add(key);
|
|
34
|
+
out.push(value);
|
|
35
|
+
}
|
|
36
|
+
return out.length === 0 ? [EVERYONE] : out;
|
|
37
|
+
}
|
|
@@ -310,6 +310,22 @@ export async function localAttachAccount(
|
|
|
310
310
|
return Promise.resolve({ agentId, station, accountId });
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
export async function localSetAllowlist(
|
|
314
|
+
subject: string,
|
|
315
|
+
agentId: string,
|
|
316
|
+
station: StationName,
|
|
317
|
+
accountId: string,
|
|
318
|
+
allowlist: string[],
|
|
319
|
+
dir = agentsDir(),
|
|
320
|
+
): Promise<string[]> {
|
|
321
|
+
const stored = ownedOrThrow(subject, agentId, dir);
|
|
322
|
+
const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
|
|
323
|
+
if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
|
|
324
|
+
account.allowlist = allowlist;
|
|
325
|
+
save(stored);
|
|
326
|
+
return Promise.resolve(allowlist);
|
|
327
|
+
}
|
|
328
|
+
|
|
313
329
|
export async function localDetachAccount(
|
|
314
330
|
subject: string,
|
|
315
331
|
agentId: string,
|
|
@@ -65,6 +65,10 @@ export function stationAgentIds(station: string): string[] {
|
|
|
65
65
|
.map(([, id]) => id);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export function allowlistForAccount(station: string, accountId: string): string[] | undefined {
|
|
69
|
+
return allowlistMap[mapKey(station, accountId)];
|
|
70
|
+
}
|
|
71
|
+
|
|
68
72
|
export function allowlistForLine(line: string): string[] | undefined {
|
|
69
73
|
const a = accountFromLine(line);
|
|
70
74
|
return a ? allowlistMap[mapKey(a.station, a.accountId)] : undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { bufferedSince, type MetroEvent } from '@metro-labs/core/events';
|
|
2
|
+
import { accountFromLine } from './map.js';
|
|
3
|
+
|
|
4
|
+
export interface RecentSender {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
at: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LIMIT = 20;
|
|
11
|
+
|
|
12
|
+
const SKIP = new Set(['', 'self', 'unknown']);
|
|
13
|
+
|
|
14
|
+
function sameAccount(from: string, station: string, accountId: string): boolean {
|
|
15
|
+
const account = accountFromLine(from);
|
|
16
|
+
return account?.station === station && account.accountId === accountId;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function senderOf(event: MetroEvent, station: string, accountId: string): { id: string; name: string } | null {
|
|
20
|
+
const from = String(event.from);
|
|
21
|
+
if (event.station !== station || !sameAccount(from, station, accountId)) return null;
|
|
22
|
+
const id = from.split('/').pop() ?? '';
|
|
23
|
+
if (SKIP.has(id)) return null;
|
|
24
|
+
return { id, name: event.fromDisplayName ?? event.fromName ?? '' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function recentSenders(station: string, accountId: string, limit = DEFAULT_LIMIT): RecentSender[] {
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
const out: RecentSender[] = [];
|
|
30
|
+
for (const { event } of bufferedSince(0).reverse()) {
|
|
31
|
+
const sender = senderOf(event, station, accountId);
|
|
32
|
+
if (sender === null || seen.has(sender.id.toLowerCase())) continue;
|
|
33
|
+
seen.add(sender.id.toLowerCase());
|
|
34
|
+
out.push({ ...sender, at: event.ts });
|
|
35
|
+
if (out.length >= limit) break;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -102,6 +102,9 @@ async function syncStations(station: StationName): Promise<void> {
|
|
|
102
102
|
function sessionApis(): SessionApis {
|
|
103
103
|
return localSessionApis({
|
|
104
104
|
syncStations,
|
|
105
|
+
reloadAgents: async () => {
|
|
106
|
+
await reloadFrom(fileSource);
|
|
107
|
+
},
|
|
105
108
|
restart: () => {
|
|
106
109
|
exitCode = RESTART_EXIT;
|
|
107
110
|
onShutdown();
|
|
@@ -4,6 +4,14 @@ import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-lab
|
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './settings.js';
|
|
7
|
+
import {
|
|
8
|
+
createClaudeSkill,
|
|
9
|
+
deleteClaudeSkill,
|
|
10
|
+
listClaudeSkills,
|
|
11
|
+
readClaudeSkill,
|
|
12
|
+
skillHomes,
|
|
13
|
+
writeClaudeSkill,
|
|
14
|
+
} from './skills.js';
|
|
7
15
|
import {
|
|
8
16
|
answerClaudeLogin,
|
|
9
17
|
claudeAccount,
|
|
@@ -54,6 +62,10 @@ const COLLECTIONS: Record<string, Handler> = {
|
|
|
54
62
|
sessions: (query, dir) => ({ sessions: listClaudeSessions(projectOf(query), dir) }),
|
|
55
63
|
memory: (query, dir) => listMemory(projectOf(query), dir),
|
|
56
64
|
settings: (_query, dir) => ({ files: listClaudeSettings(dir) }),
|
|
65
|
+
skills: (_query, dir) => ({
|
|
66
|
+
skills: listClaudeSkills(dir),
|
|
67
|
+
places: skillHomes(dir).map((home) => ({ id: home.prefix, scope: home.scope, where: home.where })),
|
|
68
|
+
}),
|
|
57
69
|
};
|
|
58
70
|
|
|
59
71
|
const ITEMS: Record<string, Handler> = {
|
|
@@ -62,17 +74,30 @@ const ITEMS: Record<string, Handler> = {
|
|
|
62
74
|
return readTranscript(projectOf(query), id, offset, limit, dir);
|
|
63
75
|
},
|
|
64
76
|
memory: (query, dir, name) => ({ name, content: readMemoryFile(projectOf(query), name, dir) }),
|
|
77
|
+
skills: (_query, dir, id) => readClaudeSkill(decodeURIComponent(id), dir),
|
|
65
78
|
};
|
|
66
79
|
|
|
67
80
|
const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/').filter(Boolean);
|
|
68
81
|
|
|
82
|
+
const seenIn = (body: Record<string, unknown>): string | null | undefined =>
|
|
83
|
+
'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
|
|
84
|
+
|
|
69
85
|
async function writeAnswer(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
|
|
70
86
|
const [head = '', item = ''] = parts(path);
|
|
71
|
-
if (head !== 'settings' || item === '') throw new ApiError('method not allowed', 405);
|
|
87
|
+
if ((head !== 'settings' && head !== 'skills') || item === '') throw new ApiError('method not allowed', 405);
|
|
72
88
|
const body = await readJsonBody(req, BODY_MAX);
|
|
73
89
|
if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
|
|
74
|
-
|
|
75
|
-
return writeClaudeSettings(item, body.text,
|
|
90
|
+
if (head === 'skills') return writeClaudeSkill(decodeURIComponent(item), body.text, seenIn(body), dir);
|
|
91
|
+
return writeClaudeSettings(item, body.text, seenIn(body), dir);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function created(req: IncomingMessage, path: string, dir: string): Promise<unknown> {
|
|
95
|
+
const rest = parts(path);
|
|
96
|
+
if (rest.length !== 1 || rest[0] !== 'skills') throw new ApiError('method not allowed', 405);
|
|
97
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
98
|
+
if (!isRecord(body) || typeof body.name !== 'string') throw new ApiError('name is required', 400);
|
|
99
|
+
const scope = typeof body.scope === 'string' ? body.scope : undefined;
|
|
100
|
+
return createClaudeSkill(body.name, scope, typeof body.text === 'string' ? body.text : undefined, dir);
|
|
76
101
|
}
|
|
77
102
|
|
|
78
103
|
const LOGIN = 'login';
|
|
@@ -94,7 +119,9 @@ async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps
|
|
|
94
119
|
|
|
95
120
|
function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
|
|
96
121
|
const [head = '', item = ''] = rest;
|
|
97
|
-
if (rest.length !== 2
|
|
122
|
+
if (rest.length !== 2) throw new ApiError('method not allowed', 405);
|
|
123
|
+
if (head === 'skills') return { deleted: deleteClaudeSkill(decodeURIComponent(item), dir) };
|
|
124
|
+
if (head !== 'sessions') throw new ApiError('method not allowed', 405);
|
|
98
125
|
deleteClaudeSession(projectOf(query), item, dir);
|
|
99
126
|
return { deleted: item };
|
|
100
127
|
}
|
|
@@ -132,6 +159,7 @@ export function handleClaudeRequest(
|
|
|
132
159
|
const [head = '', item = ''] = parts(path);
|
|
133
160
|
if (head === LOGIN) return loginAnswer(req, item, deps);
|
|
134
161
|
if (req.method === 'PUT') return writeAnswer(req, path, dir);
|
|
162
|
+
if (req.method === 'POST') return created(req, path, dir);
|
|
135
163
|
return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
|
|
136
164
|
})
|
|
137
165
|
.then((body) => {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
import { claudeDir, listClaudeProjects } from './files.js';
|
|
5
|
+
|
|
6
|
+
export const SKILL_MAX = 256 * 1024;
|
|
7
|
+
export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
8
|
+
const USER_SCOPE = 'user';
|
|
9
|
+
const FILE = 'SKILL.md';
|
|
10
|
+
const DEFAULT_MODE = 0o644;
|
|
11
|
+
const SUMMARY_MAX = 300;
|
|
12
|
+
|
|
13
|
+
export type SkillScope = 'user' | 'project';
|
|
14
|
+
|
|
15
|
+
export interface ClaudeSkill {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
scope: SkillScope;
|
|
21
|
+
where: string;
|
|
22
|
+
path: string;
|
|
23
|
+
editable: boolean;
|
|
24
|
+
updatedAt: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const frontmatterField = (text: string, field: string): string => {
|
|
28
|
+
const head = text.startsWith('---') ? text.slice(3, text.indexOf('\n---', 3)) : '';
|
|
29
|
+
for (const line of head.split('\n')) {
|
|
30
|
+
const at = line.indexOf(':');
|
|
31
|
+
if (at > 0 && line.slice(0, at).trim() === field) return line.slice(at + 1).trim().replace(/^["']|["']$/g, '').slice(0, SUMMARY_MAX);
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function entryOf(id: string, name: string, scope: SkillScope, where: string, path: string): ClaudeSkill {
|
|
37
|
+
const stat = statSync(path);
|
|
38
|
+
const editable = stat.size <= SKILL_MAX;
|
|
39
|
+
const text = editable ? readFileSync(path, 'utf8') : '';
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
name,
|
|
43
|
+
title: frontmatterField(text, 'name') || name,
|
|
44
|
+
description: frontmatterField(text, 'description'),
|
|
45
|
+
scope,
|
|
46
|
+
where,
|
|
47
|
+
path,
|
|
48
|
+
editable,
|
|
49
|
+
updatedAt: stat.mtime.toISOString(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function skillsIn(root: string, prefix: string, scope: SkillScope, where: string): ClaudeSkill[] {
|
|
54
|
+
if (!existsSync(root)) return [];
|
|
55
|
+
const out: ClaudeSkill[] = [];
|
|
56
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
57
|
+
if (!entry.isDirectory() || !SKILL_NAME_RE.test(entry.name)) continue;
|
|
58
|
+
const path = join(root, entry.name, FILE);
|
|
59
|
+
if (existsSync(path)) out.push(entryOf(`${prefix}:${entry.name}`, entry.name, scope, where, path));
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const userSkillsRoot = (dir: string): string => join(dir, 'skills');
|
|
65
|
+
|
|
66
|
+
export interface SkillHome {
|
|
67
|
+
prefix: string;
|
|
68
|
+
scope: SkillScope;
|
|
69
|
+
where: string;
|
|
70
|
+
root: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function skillHomes(dir = claudeDir()): SkillHome[] {
|
|
74
|
+
const homes: SkillHome[] = [{ prefix: USER_SCOPE, scope: USER_SCOPE, where: 'This machine', root: userSkillsRoot(dir) }];
|
|
75
|
+
for (const project of listClaudeProjects(dir)) {
|
|
76
|
+
const cwd = project.cwd;
|
|
77
|
+
if (cwd === null || !existsSync(cwd)) continue;
|
|
78
|
+
homes.push({ prefix: project.id, scope: 'project', where: cwd, root: join(cwd, '.claude', 'skills') });
|
|
79
|
+
}
|
|
80
|
+
return homes;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function listClaudeSkills(dir = claudeDir()): ClaudeSkill[] {
|
|
84
|
+
const out = skillHomes(dir).flatMap((home) => skillsIn(home.root, home.prefix, home.scope, home.where));
|
|
85
|
+
return out.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') || a.name.localeCompare(b.name));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function found(id: string, dir: string): ClaudeSkill {
|
|
89
|
+
const skill = listClaudeSkills(dir).find((entry) => entry.id === id);
|
|
90
|
+
if (skill === undefined) throw new ApiError('no such skill', 404);
|
|
91
|
+
return skill;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readClaudeSkill(id: string, dir = claudeDir()): ClaudeSkill & { text: string } {
|
|
95
|
+
const skill = found(id, dir);
|
|
96
|
+
return { ...skill, text: skill.editable ? readFileSync(skill.path, 'utf8') : '' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeAtomic(path: string, text: string): void {
|
|
100
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
101
|
+
const tmp = `${path}.metro-${String(process.pid)}`;
|
|
102
|
+
writeFileSync(tmp, text, { mode: DEFAULT_MODE });
|
|
103
|
+
renameSync(tmp, path);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertText(text: string): void {
|
|
107
|
+
if (text.length > SKILL_MAX) throw new ApiError('that is more text than a skill may hold', 413);
|
|
108
|
+
if (text.trim() === '') throw new ApiError('a skill needs some text', 400);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function writeClaudeSkill(
|
|
112
|
+
id: string,
|
|
113
|
+
text: string,
|
|
114
|
+
seenAt: string | null | undefined,
|
|
115
|
+
dir = claudeDir(),
|
|
116
|
+
): ClaudeSkill {
|
|
117
|
+
const skill = found(id, dir);
|
|
118
|
+
if (!skill.editable) throw new ApiError('that skill is too large to edit here', 409);
|
|
119
|
+
assertText(text);
|
|
120
|
+
if (seenAt !== undefined && seenAt !== skill.updatedAt)
|
|
121
|
+
throw new ApiError('that skill changed on disk since you opened it; reload it before saving', 409);
|
|
122
|
+
writeAtomic(skill.path, text);
|
|
123
|
+
return entryOf(skill.id, skill.name, skill.scope, skill.where, skill.path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const skillTemplate = (name: string): string =>
|
|
127
|
+
['---', `name: ${name}`, 'description: what this skill does, and when Claude should reach for it', '---', '', `# ${name}`, '', 'Write the instructions here.', ''].join('\n');
|
|
128
|
+
|
|
129
|
+
export function createClaudeSkill(
|
|
130
|
+
name: string,
|
|
131
|
+
scope: string | undefined,
|
|
132
|
+
text: string | undefined,
|
|
133
|
+
dir = claudeDir(),
|
|
134
|
+
): ClaudeSkill {
|
|
135
|
+
if (typeof name !== 'string' || !SKILL_NAME_RE.test(name))
|
|
136
|
+
throw new ApiError('a skill name is lowercase letters, digits and dashes, up to 64 characters', 400);
|
|
137
|
+
const prefix = scope === undefined || scope === '' ? USER_SCOPE : scope;
|
|
138
|
+
const home = skillHomes(dir).find((h) => h.prefix === prefix);
|
|
139
|
+
if (home === undefined) throw new ApiError('no such place to keep a skill', 404);
|
|
140
|
+
const path = join(home.root, name, FILE);
|
|
141
|
+
if (existsSync(path)) throw new ApiError('a skill by that name already lives there', 409);
|
|
142
|
+
const body = text === undefined || text.trim() === '' ? skillTemplate(name) : text;
|
|
143
|
+
assertText(body);
|
|
144
|
+
writeAtomic(path, body);
|
|
145
|
+
return entryOf(`${home.prefix}:${name}`, name, home.scope, home.where, path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function deleteClaudeSkill(id: string, dir = claudeDir()): string {
|
|
149
|
+
const skill = found(id, dir);
|
|
150
|
+
const folder = dirname(skill.path);
|
|
151
|
+
if (basename(folder) !== skill.name) throw new ApiError('that skill does not live in its own folder', 409);
|
|
152
|
+
rmSync(folder, { recursive: true, force: true });
|
|
153
|
+
return id;
|
|
154
|
+
}
|
|
@@ -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, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
8
|
+
import { 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;
|
|
@@ -23,7 +23,9 @@ function withAgentId(station: string, acc: unknown): unknown {
|
|
|
23
23
|
const id = typeof rec.id === 'string' ? rec.id : undefined;
|
|
24
24
|
if (id === undefined) return acc;
|
|
25
25
|
const agentId = agentIdForAccount(station, id);
|
|
26
|
-
|
|
26
|
+
if (agentId === undefined) return acc;
|
|
27
|
+
const allowlist = allowlistForAccount(station, id);
|
|
28
|
+
return allowlist === undefined ? { ...rec, agentId } : { ...rec, agentId, allowlist };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function attachAgentIds(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { AttachSessions } from '../stations/attach-session.js';
|
|
4
|
+
import { recentSenders } from '../agents/senders.js';
|
|
4
5
|
import type { AgentApiDeps } from '../agents/api.js';
|
|
5
6
|
import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
|
|
6
7
|
import type { SessionApis } from './session-apis.js';
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
localCreateAgent,
|
|
34
35
|
localDeleteAgent,
|
|
35
36
|
localDetachAccount,
|
|
37
|
+
localSetAllowlist,
|
|
36
38
|
localImportAgent,
|
|
37
39
|
localListAgents,
|
|
38
40
|
localOwnedAgentOrThrow,
|
|
@@ -46,6 +48,7 @@ import type { StationName } from '@metro-labs/core/station-names';
|
|
|
46
48
|
|
|
47
49
|
export interface LocalModeDeps {
|
|
48
50
|
syncStations: (station: StationName) => Promise<void>;
|
|
51
|
+
reloadAgents: () => Promise<void>;
|
|
49
52
|
restart: () => void;
|
|
50
53
|
stop: () => void;
|
|
51
54
|
closeAgentSession: (id: string) => Promise<boolean>;
|
|
@@ -98,6 +101,9 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
98
101
|
attachAccount: localAttachAccount,
|
|
99
102
|
detachAccount: localDetachAccount,
|
|
100
103
|
syncStations: deps.syncStations,
|
|
104
|
+
setAllowlist: localSetAllowlist,
|
|
105
|
+
recentSenders,
|
|
106
|
+
reloadAgents: deps.reloadAgents,
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
|
package/runtime/runtime.json
CHANGED