@stage-labs/metro 0.1.0-beta.75 → 0.1.0-beta.76
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
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.76",
|
|
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": {
|
|
@@ -4,6 +4,15 @@ import { apiFailure, apiSession, cors, readJsonBody, sendJson } from './api-http
|
|
|
4
4
|
import { ApiError } from './api-error.js';
|
|
5
5
|
import { isRecord } from './is-record.js';
|
|
6
6
|
import { listClaudeSettings, SETTINGS_MAX, writeClaudeSettings } from './claude-settings.js';
|
|
7
|
+
import {
|
|
8
|
+
answerClaudeLogin,
|
|
9
|
+
claudeAccount,
|
|
10
|
+
claudeInstalled,
|
|
11
|
+
claudeLoginView,
|
|
12
|
+
endClaudeLogin,
|
|
13
|
+
startClaudeLogin,
|
|
14
|
+
type LoginDeps,
|
|
15
|
+
} from './claude-login.js';
|
|
7
16
|
import {
|
|
8
17
|
claudeDir,
|
|
9
18
|
deleteClaudeSession,
|
|
@@ -16,13 +25,14 @@ import {
|
|
|
16
25
|
|
|
17
26
|
const PREFIX = '/api/claude';
|
|
18
27
|
const BODY_MAX = SETTINGS_MAX + 4096;
|
|
19
|
-
const WRITABLE = new Set(['GET', 'DELETE', 'PUT']);
|
|
28
|
+
const WRITABLE = new Set(['GET', 'DELETE', 'PUT', 'POST']);
|
|
20
29
|
const PAGE = 100;
|
|
21
30
|
const PAGE_MAX = 500;
|
|
22
31
|
|
|
23
32
|
export interface ClaudeApiDeps {
|
|
24
33
|
authorize: (subject: string) => void;
|
|
25
34
|
dir?: () => string;
|
|
35
|
+
login?: LoginDeps;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
function projectOf(query: URLSearchParams): string {
|
|
@@ -65,17 +75,38 @@ async function writeAnswer(req: IncomingMessage, path: string, dir: string): Pro
|
|
|
65
75
|
return writeClaudeSettings(item, body.text, seenAt, dir);
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
const LOGIN = 'login';
|
|
79
|
+
|
|
80
|
+
async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps): Promise<unknown> {
|
|
81
|
+
const method = req.method ?? 'GET';
|
|
82
|
+
if (id === '') {
|
|
83
|
+
if (method === 'POST') return startClaudeLogin(deps.login);
|
|
84
|
+
if (method === 'GET') return { available: claudeInstalled(), ...claudeAccount() };
|
|
85
|
+
throw new ApiError('method not allowed', 405);
|
|
86
|
+
}
|
|
87
|
+
if (method === 'GET') return claudeLoginView(id);
|
|
88
|
+
if (method === 'DELETE') return endClaudeLogin(id);
|
|
89
|
+
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
90
|
+
const body = await readJsonBody(req);
|
|
91
|
+
if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
|
|
92
|
+
return answerClaudeLogin(id, body.text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function removed(rest: string[], query: URLSearchParams, dir: string): unknown {
|
|
96
|
+
const [head = '', item = ''] = rest;
|
|
97
|
+
if (rest.length !== 2 || head !== 'sessions') throw new ApiError('method not allowed', 405);
|
|
98
|
+
deleteClaudeSession(projectOf(query), item, dir);
|
|
99
|
+
return { deleted: item };
|
|
100
|
+
}
|
|
101
|
+
|
|
68
102
|
function answer(method: string, path: string, query: URLSearchParams, dir: string): unknown {
|
|
69
103
|
const rest = parts(path);
|
|
70
|
-
const [head = '', item] = rest;
|
|
71
|
-
if (method === 'DELETE')
|
|
72
|
-
|
|
73
|
-
deleteClaudeSession(projectOf(query), item ?? '', dir);
|
|
74
|
-
return { deleted: item };
|
|
75
|
-
}
|
|
104
|
+
const [head = '', item = ''] = rest;
|
|
105
|
+
if (method === 'DELETE') return removed(rest, query, dir);
|
|
106
|
+
if (method !== 'GET') throw new ApiError('method not allowed', 405);
|
|
76
107
|
const handler = rest.length === 1 ? COLLECTIONS[head] : rest.length === 2 ? ITEMS[head] : undefined;
|
|
77
108
|
if (handler === undefined) throw new ApiError('no such route', 404);
|
|
78
|
-
return handler(query, dir, item
|
|
109
|
+
return handler(query, dir, item);
|
|
79
110
|
}
|
|
80
111
|
|
|
81
112
|
export function handleClaudeRequest(
|
|
@@ -98,6 +129,8 @@ export function handleClaudeRequest(
|
|
|
98
129
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
99
130
|
deps.authorize(session.subject);
|
|
100
131
|
const dir = (deps.dir ?? claudeDir)();
|
|
132
|
+
const [head = '', item = ''] = parts(path);
|
|
133
|
+
if (head === LOGIN) return loginAnswer(req, item, deps);
|
|
101
134
|
if (req.method === 'PUT') return writeAnswer(req, path, dir);
|
|
102
135
|
return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
|
|
103
136
|
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { ApiError } from './api-error.js';
|
|
4
|
+
import { isRecord } from './is-record.js';
|
|
5
|
+
import { errMsg, log } from './log.js';
|
|
6
|
+
|
|
7
|
+
const LOGIN_COMMAND = ['claude', 'auth', 'login', '--claudeai'];
|
|
8
|
+
const STATUS_COMMAND = ['auth', 'status', '--json'];
|
|
9
|
+
const TTL_MS = 10 * 60_000;
|
|
10
|
+
const OUTPUT_MAX = 8_000;
|
|
11
|
+
const COLS = 100;
|
|
12
|
+
const ROWS = 40;
|
|
13
|
+
const ESC = String.fromCharCode(27);
|
|
14
|
+
const CSI = /^\[[0-9;?]*[a-zA-Z]/;
|
|
15
|
+
const URL_RE = /https:\/\/[^\s"'`]+/g;
|
|
16
|
+
|
|
17
|
+
export type LoginState = 'pending' | 'done' | 'failed';
|
|
18
|
+
|
|
19
|
+
export interface LoginView {
|
|
20
|
+
id: string;
|
|
21
|
+
state: LoginState;
|
|
22
|
+
url: string | null;
|
|
23
|
+
output: string;
|
|
24
|
+
error: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Live {
|
|
28
|
+
id: string;
|
|
29
|
+
startedAt: number;
|
|
30
|
+
state: LoginState;
|
|
31
|
+
output: string;
|
|
32
|
+
error: string | null;
|
|
33
|
+
write: (text: string) => void;
|
|
34
|
+
kill: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LoginDeps {
|
|
38
|
+
command?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let live: Live | null = null;
|
|
42
|
+
|
|
43
|
+
export function plainText(raw: string): string {
|
|
44
|
+
return raw
|
|
45
|
+
.split(ESC)
|
|
46
|
+
.map((part, index) => (index === 0 ? part : part.replace(CSI, '')))
|
|
47
|
+
.join('');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loginUrlIn(output: string): string | null {
|
|
51
|
+
const found = output.match(URL_RE) ?? [];
|
|
52
|
+
const trimmed = found.map((url) => url.replace(/[.,)]+$/, ''));
|
|
53
|
+
return trimmed.find((url) => url.includes('claude.ai') || url.includes('anthropic.com')) ?? trimmed[0] ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const view = (session: Live): LoginView => ({
|
|
57
|
+
id: session.id,
|
|
58
|
+
state: session.state,
|
|
59
|
+
url: loginUrlIn(session.output),
|
|
60
|
+
output: session.output,
|
|
61
|
+
error: session.error,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function sweep(now: number): void {
|
|
65
|
+
if (live !== null && live.state !== 'pending') return;
|
|
66
|
+
if (live !== null && now - live.startedAt > TTL_MS) {
|
|
67
|
+
live.kill();
|
|
68
|
+
live = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function claudeInstalled(): boolean {
|
|
73
|
+
const run = spawnSync('claude', ['--version'], { stdio: 'ignore' });
|
|
74
|
+
return run.error === undefined && run.status === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function claudeAccount(): { signedIn: boolean; account: string | null } {
|
|
78
|
+
const run = spawnSync('claude', STATUS_COMMAND, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
79
|
+
if (run.error !== undefined || typeof run.stdout !== 'string') return { signedIn: false, account: null };
|
|
80
|
+
try {
|
|
81
|
+
const parsed: unknown = JSON.parse(run.stdout);
|
|
82
|
+
if (!isRecord(parsed)) return { signedIn: false, account: null };
|
|
83
|
+
const account = typeof parsed.email === 'string' ? parsed.email : typeof parsed.organization === 'string' ? parsed.organization : null;
|
|
84
|
+
return { signedIn: parsed.authenticated === true || parsed.loggedIn === true || account !== null, account };
|
|
85
|
+
} catch {
|
|
86
|
+
return { signedIn: false, account: null };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function note(session: Live, chunk: string): void {
|
|
91
|
+
const next = session.output + plainText(chunk);
|
|
92
|
+
session.output = next.length > OUTPUT_MAX ? next.slice(next.length - OUTPUT_MAX) : next;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now()): LoginView {
|
|
96
|
+
sweep(now);
|
|
97
|
+
if (live !== null && live.state === 'pending') return view(live);
|
|
98
|
+
const command = deps.command ?? LOGIN_COMMAND;
|
|
99
|
+
const session: Live = {
|
|
100
|
+
id: randomBytes(9).toString('base64url'),
|
|
101
|
+
startedAt: now,
|
|
102
|
+
state: 'pending',
|
|
103
|
+
output: '',
|
|
104
|
+
error: null,
|
|
105
|
+
write: () => undefined,
|
|
106
|
+
kill: () => undefined,
|
|
107
|
+
};
|
|
108
|
+
const terminal = new Bun.Terminal({
|
|
109
|
+
cols: COLS,
|
|
110
|
+
rows: ROWS,
|
|
111
|
+
data: (_term, chunk: Uint8Array) => {
|
|
112
|
+
note(session, Buffer.from(chunk).toString('utf8'));
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const proc = Bun.spawn(command, { terminal, env: { ...process.env, TERM: 'xterm-256color' } });
|
|
116
|
+
session.write = (text: string) => {
|
|
117
|
+
terminal.write(Buffer.from(`${text}\r`, 'utf8'));
|
|
118
|
+
};
|
|
119
|
+
session.kill = () => {
|
|
120
|
+
proc.kill();
|
|
121
|
+
terminal.close();
|
|
122
|
+
};
|
|
123
|
+
proc.exited
|
|
124
|
+
.then((code) => {
|
|
125
|
+
session.state = code === 0 ? 'done' : 'failed';
|
|
126
|
+
if (code !== 0) session.error = `the login ended with status ${String(code)}`;
|
|
127
|
+
terminal.close();
|
|
128
|
+
log.info({ state: session.state }, 'claude-login: the official login finished');
|
|
129
|
+
})
|
|
130
|
+
.catch((err: unknown) => {
|
|
131
|
+
session.state = 'failed';
|
|
132
|
+
session.error = errMsg(err);
|
|
133
|
+
});
|
|
134
|
+
live = session;
|
|
135
|
+
log.info({ command: command[0] }, 'claude-login: started');
|
|
136
|
+
return view(session);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function current(id: string): Live {
|
|
140
|
+
const session = live;
|
|
141
|
+
if (session?.id !== id) throw new ApiError('that sign-in is over; start another one', 404);
|
|
142
|
+
return session;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function claudeLoginView(id: string, now = Date.now()): LoginView {
|
|
146
|
+
sweep(now);
|
|
147
|
+
return view(current(id));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function answerClaudeLogin(id: string, text: string): LoginView {
|
|
151
|
+
const session = current(id);
|
|
152
|
+
if (session.state !== 'pending') throw new ApiError('that sign-in has already finished', 409);
|
|
153
|
+
session.write(text);
|
|
154
|
+
return view(session);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function endClaudeLogin(id: string): { ended: true } {
|
|
158
|
+
const session = current(id);
|
|
159
|
+
session.kill();
|
|
160
|
+
live = null;
|
|
161
|
+
return { ended: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const forgetClaudeLogin = (): void => {
|
|
165
|
+
live?.kill();
|
|
166
|
+
live = null;
|
|
167
|
+
};
|
package/runtime/runtime.json
CHANGED