@stage-labs/metro 0.1.0-beta.310 → 0.1.0-beta.311

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.
Files changed (30) hide show
  1. package/dist/claude-env.js +1 -1
  2. package/package.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/package.json +1 -0
  4. package/runtime/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -3
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +1 -36
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +4 -13
  9. package/runtime/node_modules/@metro-labs/daemon/src/gateway/delivered.ts +16 -9
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/effective.ts +4 -15
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/fallback.ts +5 -4
  12. package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +6 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +31 -49
  14. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +18 -32
  15. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +2 -40
  16. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-leftovers.ts +24 -3
  17. package/runtime/node_modules/@metro-labs/daemon/src/gateway/subscription.ts +1 -61
  18. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +11 -55
  19. package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +1 -66
  20. package/runtime/runtime.json +1 -1
  21. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
  22. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/claude-token.ts +3 -0
  23. package/runtime/sdk-runner/runner.json +1 -1
  24. package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +0 -243
  25. package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +0 -38
  26. package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +0 -128
  27. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-logins.ts +0 -192
  28. package/runtime/node_modules/@metro-labs/daemon/src/gateway/claude-store.ts +0 -59
  29. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +0 -40
  30. package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +0 -77
@@ -26,7 +26,7 @@ export interface Connection {
26
26
  zdr: boolean;
27
27
  revision: number;
28
28
  codex: CodexAccess | null;
29
- claude: string | null;
29
+ claudeToken: string;
30
30
  account: Account;
31
31
  signedOut: boolean;
32
32
  }
@@ -48,48 +48,15 @@ export interface Route {
48
48
  model: string;
49
49
  }
50
50
 
51
- export const LOCAL_PREFIX = 'local:';
52
- export const SESSION_LOGIN = `${LOCAL_PREFIX}session`;
53
51
  export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's Models page.";
54
52
 
55
53
  const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
56
54
  const SMALL_RE = /haiku/i;
57
- const NO_ACCOUNT: Account = { email: null, plan: null, method: null };
58
55
 
59
56
  export const isProvider = (value: unknown): value is Provider => typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
60
57
 
61
58
  export const emptySetup = (): ModelSetup => ({ primary: null, fallbacks: [], connections: [], source: null });
62
59
 
63
- export const loginRef = (login: string): string => `${LOCAL_PREFIX}${login}`;
64
-
65
- const localConnection = (id: string, label: string, claude: string | null, account: Account, missing: boolean): Connection => ({
66
- id,
67
- provider: 'anthropic',
68
- label,
69
- model: '',
70
- apiKey: '',
71
- region: '',
72
- zdr: false,
73
- revision: 0,
74
- codex: null,
75
- claude,
76
- account,
77
- signedOut: missing,
78
- });
79
-
80
- export const sessionConnection = (): Connection => localConnection(SESSION_LOGIN, 'Claude Code login', null, NO_ACCOUNT, false);
81
-
82
- export interface LocalLogin {
83
- id: string;
84
- email: string | null;
85
- plan: string | null;
86
- }
87
-
88
- export function loginConnection(login: string, known: LocalLogin | undefined): Connection {
89
- const account = known === undefined ? NO_ACCOUNT : { email: known.email, plan: known.plan, method: null };
90
- return localConnection(loginRef(login), known?.email ?? 'Claude login', login, account, known === undefined);
91
- }
92
-
93
60
  export const connectionOf = (setup: ModelSetup, id: string): Connection | null => setup.connections.find((c) => c.id === id) ?? null;
94
61
 
95
62
  export function primaryRoute(setup: ModelSetup): Route | null {
@@ -99,13 +66,8 @@ export function primaryRoute(setup: ModelSetup): Route | null {
99
66
  return { connection: conn, model: primary.model === '' ? conn.model : primary.model };
100
67
  }
101
68
 
102
- const MISSING_LOGIN = 'This Claude login is not on this box: sign in again on the Model page.';
103
-
104
69
  const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
105
- anthropic: [
106
- [(r) => r.connection.claude !== null && r.connection.signedOut, MISSING_LOGIN],
107
- [(r) => !r.connection.id.startsWith(LOCAL_PREFIX) && r.connection.apiKey === '', "Anthropic has no API key: add it on the organization's Models page."],
108
- ],
70
+ anthropic: [[(r) => r.connection.apiKey === '' && r.connection.claudeToken === '', "Anthropic has no API key or Claude subscription: add one on the organization's Models page."]],
109
71
  bedrock: [
110
72
  [(r) => r.connection.apiKey === '', "Bedrock has no API key: add it on the organization's Models page."],
111
73
  [(r) => r.connection.region === '', "Bedrock needs a region: add it on the organization's Models page."],
@@ -1,9 +1,13 @@
1
1
  import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  import { errMsg, log } from '@metro-labs/core/log';
4
5
  import { agentsDir } from '../agents/files.js';
6
+ import { existsSync } from '../agent-user/agent-fs.js';
7
+ import { removeHome } from '../agent-user/home-fs.js';
8
+ import { agentUser, agentUserExpected } from '../agent-user/user.js';
5
9
 
6
- const LEFTOVER_RE = /^chatgpt-host\.json(?:\.tmp-[0-9]+)?$/;
10
+ const LEFTOVER_RE = /^(?:chatgpt-host|model)\.json(?:\.tmp-[0-9]+)?$/;
7
11
 
8
12
  const codeOf = (err: unknown): unknown => (err instanceof Error && 'code' in err ? err.code : undefined);
9
13
 
@@ -30,9 +34,26 @@ function removeOne(path: string): boolean {
30
34
  }
31
35
  }
32
36
 
33
- export function removeModelLeftovers(dir = agentsDir()): number {
37
+ export function claudeLoginsDir(): string | null {
38
+ const user = agentUser();
39
+ if (user === null && agentUserExpected()) return null;
40
+ return join(user?.home ?? homedir(), '.metro', 'claude-logins');
41
+ }
42
+
43
+ function removeClaudeLogins(dir: string | null): number {
44
+ if (dir === null || !existsSync(dir)) return 0;
45
+ try {
46
+ removeHome(dir, true);
47
+ return 1;
48
+ } catch (err) {
49
+ log.warn({ err: errMsg(err), dir }, 'org-models: could not remove the folder of the old Claude logins');
50
+ return 0;
51
+ }
52
+ }
53
+
54
+ export function removeModelLeftovers(dir = agentsDir(), logins: string | null = claudeLoginsDir()): number {
34
55
  try {
35
- const removed = namesIn(dir).filter((name) => removeOne(join(dir, name))).length;
56
+ const removed = namesIn(dir).filter((name) => removeOne(join(dir, name))).length + removeClaudeLogins(logins);
36
57
  log.info({ removed }, 'org-models: removed the files of the old model setup');
37
58
  return removed;
38
59
  } catch (err) {
@@ -1,71 +1,11 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
- import { errMsg } from '@metro-labs/core/log';
4
3
  import { assembleMessage, SseParser, type SseEvent } from './frames.js';
5
- import { GatewayError, relayFrames, type Watch } from './forward.js';
4
+ import { relayFrames, type Watch } from './forward.js';
6
5
  import { UsageScanner } from './usage.js';
7
6
 
8
7
  const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
9
8
 
10
- interface Slot<T> {
11
- refreshing: Promise<T> | null;
12
- latest: T | null;
13
- }
14
-
15
- export type TokenState<T> = Map<string, Slot<T>>;
16
-
17
- export interface TokenSource<T extends { savedAt: string }> {
18
- label: string;
19
- stale: (tokens: T) => boolean;
20
- refresh: (tokens: T) => Promise<T>;
21
- save: (id: string, tokens: T) => void;
22
- }
23
-
24
- function slotFor<T>(state: TokenState<T>, id: string): Slot<T> {
25
- const found = state.get(id);
26
- if (found !== undefined) return found;
27
- const made: Slot<T> = { refreshing: null, latest: null };
28
- state.set(id, made);
29
- return made;
30
- }
31
-
32
- const newerThan = (a: { savedAt: string }, b: { savedAt: string }): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
33
-
34
- export function refreshed<T extends { savedAt: string }>(state: TokenState<T>, id: string, tokens: T, source: TokenSource<T>): Promise<T> {
35
- const slot = slotFor(state, id);
36
- const latest = slot.latest;
37
- if (latest !== null && newerThan(latest, tokens) && !source.stale(latest)) return Promise.resolve(latest);
38
- if (slot.refreshing !== null) return slot.refreshing;
39
- const run = source
40
- .refresh(tokens)
41
- .then((fresh) => {
42
- source.save(id, fresh);
43
- slot.latest = fresh;
44
- return fresh;
45
- })
46
- .catch((err: unknown) => {
47
- throw new GatewayError(403, 'permission_error', `${source.label} sign-in expired (${errMsg(err)}): connect again on the Model page`);
48
- })
49
- .finally(() => {
50
- slot.refreshing = null;
51
- });
52
- slot.refreshing = run;
53
- return run;
54
- }
55
-
56
- export function currentOf<T extends { savedAt: string }>(state: TokenState<T>, id: string, tokens: T | null, source: TokenSource<T>, missing: string): Promise<T> {
57
- if (tokens === null) throw new GatewayError(400, 'invalid_request_error', missing);
58
- return source.stale(tokens) ? refreshed(state, id, tokens, source) : Promise.resolve(tokens);
59
- }
60
-
61
- export async function reach<T>(current: () => Promise<T>, again: (tokens: T) => Promise<T>, send: (tokens: T) => Promise<Response>): Promise<Response> {
62
- const tokens = await current();
63
- const upstream = await send(tokens);
64
- if (upstream.status !== 401) return upstream;
65
- await upstream.body?.cancel();
66
- return send(await again(tokens));
67
- }
68
-
69
9
  export function errorKind(status: number, invalid: readonly number[] = [400, 404]): string {
70
10
  if (status === 401 || status === 403) return 'permission_error';
71
11
  if (status === 429) return 'rate_limit_error';
@@ -1,20 +1,14 @@
1
1
  import { errMsg, log } from '@metro-labs/core/log';
2
- import { readClaudeUsage } from '../claude/usage-probe.js';
3
2
  import { openrouterKey } from './openrouter.js';
4
- import { claudeLoginUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
5
- import { loginRef, primaryRoute, SESSION_LOGIN, type Connection, type ModelSetup } from './model-config.js';
6
- import { probeClaudeLogin, type ClaudeLoginDeps } from './claude-logins.js';
7
- import type { ClaudeLogin } from './claude-store.js';
3
+ import { mayProbe, noteUsage, openrouterUsage, usageOf } from './usage.js';
4
+ import type { Connection, ModelSetup } from './model-config.js';
8
5
 
9
6
  export const CREDITS_TTL_MS = 5 * 60_000;
10
7
  const WAIT_MS = 4_000;
11
- const LOGIN_PROBE = 'claude-login';
12
8
 
13
9
  export interface UsageDeps {
14
10
  fetchImpl?: typeof fetch;
15
11
  openrouterBase?: string;
16
- claudeUsage?: (env?: Record<string, string>) => Promise<unknown>;
17
- claudeLogins?: ClaudeLoginDeps;
18
12
  usageWaitMs?: number;
19
13
  }
20
14
 
@@ -23,47 +17,15 @@ const fresh = (id: string, now: number): boolean => {
23
17
  return seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS;
24
18
  };
25
19
 
26
- const due = (id: string, now: number): boolean => !fresh(id, now) && mayProbe(id, now, CREDITS_TTL_MS);
27
-
28
- async function probe(what: string, run: () => Promise<void>): Promise<void> {
20
+ async function refreshKey(conn: Connection, deps: UsageDeps, now: number): Promise<void> {
21
+ if (conn.provider !== 'openrouter' || conn.apiKey === '' || fresh(conn.id, now) || !mayProbe(conn.id, now, CREDITS_TTL_MS)) return;
29
22
  try {
30
- await run();
23
+ noteUsage(conn.id, openrouterUsage(await openrouterKey(conn.apiKey, deps.openrouterBase, deps.fetchImpl), new Date(now)));
31
24
  } catch (err) {
32
- log.warn({ err: errMsg(err) }, `model-api: could not read the ${what}`);
25
+ log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter key spend');
33
26
  }
34
27
  }
35
28
 
36
- async function refreshKey(conn: Connection, deps: UsageDeps, now: number): Promise<void> {
37
- if (conn.provider !== 'openrouter' || conn.apiKey === '' || !due(conn.id, now)) return;
38
- await probe('OpenRouter key spend', async () => {
39
- noteUsage(conn.id, openrouterUsage(await openrouterKey(conn.apiKey, deps.openrouterBase, deps.fetchImpl), new Date(now)));
40
- });
41
- }
42
-
43
- const hasFallbacks = (setup: ModelSetup): boolean => setup.fallbacks.length > 0;
44
-
45
- async function refreshOwnLogin(login: string, setup: ModelSetup, deps: UsageDeps, now: number): Promise<void> {
46
- const key = loginRef(login);
47
- if ((fresh(key, now) && !hasFallbacks(setup)) || !mayProbe(key, now, CREDITS_TTL_MS)) return;
48
- await probe('usage of a Claude login', async () => {
49
- const usage = claudeLoginUsage(await probeClaudeLogin(login, deps.claudeLogins), new Date(now));
50
- if (usage !== null) mergeUsage(key, usage);
51
- });
52
- }
53
-
54
- const ridesSession = (setup: ModelSetup): boolean => primaryRoute(setup) === null || setup.connections.some((c) => c.id === SESSION_LOGIN);
55
-
56
- async function refreshSession(setup: ModelSetup, deps: UsageDeps, now: number): Promise<void> {
57
- if (!ridesSession(setup) || (fresh(SESSION_LOGIN, now) && !hasFallbacks(setup))) return;
58
- if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
59
- await probe('usage of the Claude Code login', async () => {
60
- const usage = claudeLoginUsage(await (deps.claudeUsage ?? (() => readClaudeUsage()))(), new Date(now));
61
- if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
62
- });
63
- const usage = probeAnswer(LOGIN_PROBE);
64
- if (usage !== undefined && now - Date.parse(usage.at) < CREDITS_TTL_MS) mergeUsage(SESSION_LOGIN, usage);
65
- }
66
-
67
29
  async function within(ms: number, work: Promise<unknown>): Promise<void> {
68
30
  let timer: ReturnType<typeof setTimeout> | undefined;
69
31
  const waited = new Promise<void>((resolve) => {
@@ -73,21 +35,15 @@ async function within(ms: number, work: Promise<unknown>): Promise<void> {
73
35
  clearTimeout(timer);
74
36
  }
75
37
 
76
- export async function refreshUsage(setup: ModelSetup, logins: ClaudeLogin[], deps: UsageDeps = {}, now = Date.now()): Promise<void> {
77
- const own = new Set([...logins.map((l) => l.id), ...setup.connections.flatMap((c) => (c.claude === null || c.signedOut ? [] : [c.claude]))]);
78
- const work = Promise.all([
79
- ...setup.connections.map((conn) => refreshKey(conn, deps, now)),
80
- ...[...own].map((login) => refreshOwnLogin(login, setup, deps, now)),
81
- refreshSession(setup, deps, now),
82
- ]);
83
- await within(deps.usageWaitMs ?? WAIT_MS, work);
38
+ export async function refreshUsage(setup: ModelSetup, deps: UsageDeps = {}, now = Date.now()): Promise<void> {
39
+ await within(deps.usageWaitMs ?? WAIT_MS, Promise.all(setup.connections.map((conn) => refreshKey(conn, deps, now))));
84
40
  }
85
41
 
86
- export function watchFallbackUsage(setup: () => ModelSetup, logins: () => ClaudeLogin[], deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
42
+ export function watchFallbackUsage(setup: () => ModelSetup, deps: UsageDeps = {}, every = CREDITS_TTL_MS): void {
87
43
  const run = (): void => {
88
44
  const current = setup();
89
- if (!hasFallbacks(current)) return;
90
- refreshUsage(current, logins(), deps).catch((err: unknown) => {
45
+ if (current.fallbacks.length === 0) return;
46
+ refreshUsage(current, deps).catch((err: unknown) => {
91
47
  log.warn({ err: errMsg(err) }, 'model-api: could not refresh the usage of the fallback models');
92
48
  });
93
49
  };
@@ -1,6 +1,3 @@
1
- import { isRecord } from '@metro-labs/core/is-record';
2
- import { stringOf } from '@metro-labs/http/api-http';
3
-
4
1
  type Key = string;
5
2
 
6
3
  export interface UsageWindow {
@@ -31,14 +28,13 @@ const latest = new Map<Key, Reported>();
31
28
  const stamps = new Map<Key, Map<string, number>>();
32
29
  const tallies = new Map<Key, Tally>();
33
30
  const probed = new Map<Key, number>();
34
- const answers = new Map<Key, Reported>();
35
31
 
36
32
  export const noteUsage = (key: Key, usage: Reported): void => {
37
33
  latest.set(key, usage);
38
34
  stamps.set(key, new Map(usage.windows.map((w) => [w.label, Date.parse(usage.at)])));
39
35
  };
40
36
 
41
- export function mergeUsage(key: Key, usage: Reported): void {
37
+ function mergeUsage(key: Key, usage: Reported): void {
42
38
  const seen = latest.get(key);
43
39
  const marks = stamps.get(key);
44
40
  if (seen === undefined || marks === undefined) {
@@ -66,25 +62,11 @@ export function usageSeen(): Record<Key, ProviderUsage> {
66
62
 
67
63
  export const usageOf = (key: Key): Reported | undefined => latest.get(key);
68
64
 
69
- export const forgetOne = (key: Key): void => {
70
- latest.delete(key);
71
- stamps.delete(key);
72
- tallies.delete(key);
73
- probed.delete(key);
74
- };
75
-
76
- export const forgetReported = (key: Key): void => {
77
- latest.delete(key);
78
- stamps.delete(key);
79
- probed.delete(key);
80
- };
81
-
82
65
  export const forgetUsage = (): void => {
83
66
  latest.clear();
84
67
  stamps.clear();
85
68
  tallies.clear();
86
69
  probed.clear();
87
- answers.clear();
88
70
  };
89
71
 
90
72
  export function mayProbe(key: Key, now: number, every: number): boolean {
@@ -93,12 +75,6 @@ export function mayProbe(key: Key, now: number, every: number): boolean {
93
75
  return true;
94
76
  }
95
77
 
96
- export const probeAnswer = (key: Key): Reported | undefined => answers.get(key);
97
-
98
- export const keepProbeAnswer = (key: Key, usage: Reported): void => {
99
- answers.set(key, usage);
100
- };
101
-
102
78
  export interface Counted {
103
79
  input: number;
104
80
  output: number;
@@ -265,47 +241,6 @@ export function codexUsage(headers: Headers, now = new Date()): Reported | null
265
241
  return { windows, note: reached === null || reached === '' ? null : reached.replaceAll('_', ' '), at: now.toISOString() };
266
242
  }
267
243
 
268
- const present = (w: UsageWindow | null): w is UsageWindow => w !== null;
269
-
270
- const textOf = (raw: unknown): string | null => (typeof raw === 'number' || typeof raw === 'string' ? String(raw) : null);
271
-
272
- const numberOf = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
273
-
274
- const CLAUDE_WINDOWS: [string, string][] = [
275
- ['five_hour', '5-hour window'],
276
- ['seven_day', 'Weekly, all models'],
277
- ['seven_day_sonnet', 'Weekly, Sonnet'],
278
- ['seven_day_opus', 'Weekly, Opus'],
279
- ];
280
-
281
- function percentWindow(label: string, raw: unknown, now: Date): UsageWindow | null {
282
- if (!isRecord(raw)) return null;
283
- const percent = numberOf(raw.utilization) ?? numberOf(raw.percent);
284
- if (percent === null) return null;
285
- return { label, used: clamp(percent / 100), resetAt: whenFrom(textOf(raw.resets_at), now), detail: null };
286
- }
287
-
288
- function scopedName(entry: Record<string, unknown>): string {
289
- if (stringOf(entry.display_name) !== '') return stringOf(entry.display_name);
290
- if (entry.kind !== 'weekly_scoped' || !isRecord(entry.scope) || !isRecord(entry.scope.model)) return '';
291
- return stringOf(entry.scope.model.display_name);
292
- }
293
-
294
- function scopedWindows(limits: Record<string, unknown>, now: Date): (UsageWindow | null)[] {
295
- const listed = [limits.model_scoped, limits.limits].flatMap((list): unknown[] => (Array.isArray(list) ? (list as unknown[]) : []));
296
- return listed.map((entry) => {
297
- const name = isRecord(entry) ? scopedName(entry) : '';
298
- return name === '' ? null : percentWindow(`Weekly, ${name}`, entry, now);
299
- });
300
- }
301
-
302
- export function claudeLoginUsage(limits: unknown, now = new Date()): Reported | null {
303
- if (!isRecord(limits)) return null;
304
- const all = [...CLAUDE_WINDOWS.map(([key, label]) => percentWindow(label, limits[key], now)), ...scopedWindows(limits, now)].filter(present);
305
- const windows = all.filter((w, index) => all.findIndex((other) => other.label === w.label) === index);
306
- return windows.length === 0 ? null : { windows, note: null, at: now.toISOString() };
307
- }
308
-
309
244
  export function noteUsageHeaders(kind: 'anthropic' | 'codex', key: Key, headers: Headers, now = new Date()): void {
310
245
  const usage = kind === 'anthropic' ? anthropicUsage(headers, now) : codexUsage(headers, now);
311
246
  if (usage === null) return;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.310"
2
+ "version": "0.1.0-beta.311"
3
3
  }
@@ -18,6 +18,7 @@
18
18
  "./train-error": "./src/train-error.ts",
19
19
  "./trains/protocol": "./src/protocol.ts",
20
20
  "./ids": "./src/ids.ts",
21
+ "./claude-token": "./src/claude-token.ts",
21
22
  "./tickets": "./src/tickets.ts",
22
23
  "./station-names": "./src/station-names.ts",
23
24
  "./str": "./src/str.ts",
@@ -0,0 +1,3 @@
1
+ const TOKEN_RE = /^sk-ant-oat01-[A-Za-z0-9_-]{32,512}$/;
2
+
3
+ export const isClaudeToken = (value: string): boolean => TOKEN_RE.test(value);
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.310",
2
+ "version": "0.1.0-beta.311",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,243 +0,0 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { spawnSync } from 'node:child_process';
3
- import { markOnboardingDone } from './onboarding.js';
4
- import { ApiError } from '@metro-labs/http/api-error';
5
- import { isRecord } from '@metro-labs/core/is-record';
6
- import { errMsg, log } from '@metro-labs/core/log';
7
- import { agentCommand, asAgent, CLAUDE_ENV, claudeBin } from '../agent-user/user.js';
8
-
9
- const LOGIN_ARGS = ['auth', 'login', '--claudeai'];
10
- const STATUS_COMMAND = ['auth', 'status', '--json'];
11
- const TTL_MS = 10 * 60_000;
12
- const OUTPUT_MAX = 8_000;
13
- const COLS = 100;
14
- const ROWS = 40;
15
- const ESC = String.fromCharCode(27);
16
- const CSI = /^\[[0-9;?]*[a-zA-Z]/;
17
- const URL_RE = /https:\/\/[^\s"'`]+/g;
18
-
19
- type LoginState = 'pending' | 'done' | 'failed';
20
-
21
- export interface LoginView {
22
- id: string;
23
- state: LoginState;
24
- url: string | null;
25
- output: string;
26
- error: string | null;
27
- }
28
-
29
- export interface LoginTarget {
30
- key: string;
31
- env: Record<string, string>;
32
- settle: (ok: boolean) => Promise<string | null>;
33
- }
34
-
35
- interface Live {
36
- id: string;
37
- startedAt: number;
38
- state: LoginState;
39
- output: string;
40
- error: string | null;
41
- target: LoginTarget | null;
42
- write: (text: string) => void;
43
- kill: () => void;
44
- }
45
-
46
- export interface LoginDeps {
47
- command?: string[];
48
- }
49
-
50
- let live: Live | null = null;
51
-
52
- export function plainText(raw: string): string {
53
- return raw
54
- .split(ESC)
55
- .map((part, index) => (index === 0 ? part : part.replace(CSI, '')))
56
- .join('');
57
- }
58
-
59
- export function loginUrlIn(output: string): string | null {
60
- const found = output.match(URL_RE) ?? [];
61
- const trimmed = found.map((url) => url.replace(/[.,)]+$/, ''));
62
- return trimmed.find((url) => url.includes('claude.ai') || url.includes('anthropic.com')) ?? trimmed[0] ?? null;
63
- }
64
-
65
- const view = (session: Live): LoginView => ({
66
- id: session.id,
67
- state: session.state,
68
- url: loginUrlIn(session.output),
69
- output: session.output,
70
- error: session.error,
71
- });
72
-
73
- function sweep(now: number): void {
74
- if (live !== null && live.state !== 'pending') return;
75
- if (live !== null && now - live.startedAt > TTL_MS) {
76
- live.kill();
77
- live = null;
78
- }
79
- }
80
-
81
- const CHECK_MS = 30_000;
82
-
83
- interface Checked<T> {
84
- at: number;
85
- value: T;
86
- }
87
-
88
- const checks: { installed?: Checked<boolean>; account?: Checked<ClaudeAccount> } = {};
89
-
90
- function remembered<T>(slot: Checked<T> | undefined, read: () => T, keep: (c: Checked<T>) => void): T {
91
- const now = Date.now();
92
- if (slot !== undefined && now - slot.at < CHECK_MS) return slot.value;
93
- const value = read();
94
- keep({ at: now, value });
95
- return value;
96
- }
97
-
98
- export function forgetClaudeChecks(): void {
99
- delete checks.installed;
100
- delete checks.account;
101
- }
102
-
103
- export function claudeInstalled(): boolean {
104
- return remembered(checks.installed, () => {
105
- const run = spawnSync(...asAgent(claudeBin(), ['--version'], CLAUDE_ENV), { stdio: 'ignore', env: { ...process.env, ...CLAUDE_ENV } });
106
- return run.error === undefined && run.status === 0;
107
- }, (c) => {
108
- checks.installed = c;
109
- });
110
- }
111
-
112
- export interface ClaudeAccount {
113
- signedIn: boolean;
114
- account: string | null;
115
- plan: string | null;
116
- }
117
-
118
- const SIGNED_OUT: ClaudeAccount = { signedIn: false, account: null, plan: null };
119
-
120
- export function claudeAccount(): ClaudeAccount {
121
- return remembered(checks.account, () => claudeAccountIn({}), (c) => {
122
- checks.account = c;
123
- });
124
- }
125
-
126
- const textOf = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
127
-
128
- export function claudeAccountIn(extra: Record<string, string>): ClaudeAccount {
129
- const env = { ...CLAUDE_ENV, ...extra };
130
- const run = spawnSync(...asAgent(claudeBin(), STATUS_COMMAND, env), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, ...env } });
131
- if (run.error !== undefined || typeof run.stdout !== 'string') return SIGNED_OUT;
132
- try {
133
- const parsed: unknown = JSON.parse(run.stdout);
134
- if (!isRecord(parsed)) return SIGNED_OUT;
135
- const account = textOf(parsed.email) ?? textOf(parsed.organization) ?? textOf(parsed.orgName);
136
- return { signedIn: parsed.authenticated === true || parsed.loggedIn === true || account !== null, account, plan: textOf(parsed.subscriptionType) };
137
- } catch {
138
- return SIGNED_OUT;
139
- }
140
- }
141
-
142
- function note(session: Live, chunk: string): void {
143
- const next = session.output + plainText(chunk);
144
- session.output = next.length > OUTPUT_MAX ? next.slice(next.length - OUTPUT_MAX) : next;
145
- }
146
-
147
- async function verdict(session: Live, code: number): Promise<string | null> {
148
- const problem = code === 0 ? null : `the login ended with status ${String(code)}`;
149
- if (session.target === null) return problem;
150
- try {
151
- return (await session.target.settle(problem === null)) ?? problem;
152
- } catch (err) {
153
- return errMsg(err);
154
- }
155
- }
156
-
157
- function runningFor(target: LoginTarget | null): Live | null {
158
- if (live === null || live.state !== 'pending') return null;
159
- if (live.target?.key === target?.key) return live;
160
- live.kill();
161
- live = null;
162
- return null;
163
- }
164
-
165
- export function startClaudeLogin(deps: LoginDeps = {}, now = Date.now(), target: LoginTarget | null = null): LoginView {
166
- sweep(now);
167
- const running = runningFor(target);
168
- if (running !== null) return view(running);
169
- const env = { TERM: 'xterm-256color', ...CLAUDE_ENV, ...target?.env };
170
- const command = deps.command ?? agentCommand([claudeBin(), ...LOGIN_ARGS], env);
171
- const session: Live = {
172
- id: randomBytes(9).toString('base64url'),
173
- startedAt: now,
174
- state: 'pending',
175
- output: '',
176
- error: null,
177
- target,
178
- write: () => undefined,
179
- kill: () => undefined,
180
- };
181
- const terminal = new Bun.Terminal({
182
- cols: COLS,
183
- rows: ROWS,
184
- data: (_term, chunk: Uint8Array) => {
185
- note(session, Buffer.from(chunk).toString('utf8'));
186
- },
187
- });
188
- const proc = Bun.spawn(command, { terminal, env: { ...process.env, ...env } });
189
- session.write = (text: string) => {
190
- terminal.write(Buffer.from(`${text}\r`, 'utf8'));
191
- };
192
- session.kill = () => {
193
- proc.kill();
194
- terminal.close();
195
- };
196
- proc.exited
197
- .then(async (code) => {
198
- session.write = () => undefined;
199
- terminal.close();
200
- forgetClaudeChecks();
201
- session.error = await verdict(session, code);
202
- session.state = session.error === null ? 'done' : 'failed';
203
- const onboarding = session.error === null ? markOnboardingDone() : 'skipped';
204
- log.info({ state: session.state, onboarding, own: session.target !== null }, 'claude-login: the official login finished');
205
- })
206
- .catch((err: unknown) => {
207
- session.state = 'failed';
208
- session.error = errMsg(err);
209
- });
210
- live = session;
211
- log.info({ command: command[0] }, 'claude-login: started');
212
- return view(session);
213
- }
214
-
215
- function current(id: string): Live {
216
- const session = live;
217
- if (session?.id !== id) throw new ApiError('that sign-in is over; start another one', 404);
218
- return session;
219
- }
220
-
221
- export function claudeLoginView(id: string, now = Date.now()): LoginView {
222
- sweep(now);
223
- return view(current(id));
224
- }
225
-
226
- export function answerClaudeLogin(id: string, text: string): LoginView {
227
- const session = current(id);
228
- if (session.state !== 'pending') throw new ApiError('that sign-in has already finished', 409);
229
- session.write(text);
230
- return view(session);
231
- }
232
-
233
- export function endClaudeLogin(id: string): { ended: true } {
234
- const session = current(id);
235
- session.kill();
236
- live = null;
237
- return { ended: true };
238
- }
239
-
240
- export const forgetClaudeLogin = (): void => {
241
- live?.kill();
242
- live = null;
243
- };