@scrylog/cli 0.1.4 → 0.3.0
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/README.md +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode/collector.ts +4 -2
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +109 -14
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/index.ts +2 -1
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- package/src/tui/index.ts +0 -193
package/src/usage/planner.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { TokenResolver } from "./resolver";
|
|
2
|
-
import type { ClaudePlannerDecision, ClaudeDataSource, ClaudeOAuthCredentials, ClaudeWebSession } from "./types";
|
|
2
|
+
import type { ClaudePlannerDecision, ClaudeDataSource, ClaudeOAuthCredentials, ClaudePlannedRoute, ClaudeWebSession } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Web session resolver with planning-safe operations. Planning only observes
|
|
6
|
+
* the in-memory session cache and cheap import-source checks; browser cookie
|
|
7
|
+
* import happens exclusively when a Web route executes.
|
|
8
|
+
*/
|
|
9
|
+
export interface ClaudeWebSourceResolver extends TokenResolver<ClaudeWebSession> {
|
|
10
|
+
/** Cache-only session resolution; never reads browser stores. */
|
|
11
|
+
resolveCached(): Promise<ClaudeWebSession | null>;
|
|
12
|
+
/** True when an import could yield candidates (cheap existence checks). */
|
|
13
|
+
hasImportSources(): Promise<boolean>;
|
|
14
|
+
}
|
|
3
15
|
|
|
4
16
|
export interface ClaudeSourcePlannerConfig {
|
|
5
17
|
dataSource: ClaudeDataSource;
|
|
@@ -10,45 +22,97 @@ export class ClaudeSourcePlanner {
|
|
|
10
22
|
constructor(
|
|
11
23
|
private readonly config: ClaudeSourcePlannerConfig,
|
|
12
24
|
private readonly oauthResolver: TokenResolver<ClaudeOAuthCredentials>,
|
|
13
|
-
private readonly webResolver:
|
|
25
|
+
private readonly webResolver: ClaudeWebSourceResolver,
|
|
14
26
|
private readonly cliResolver: TokenResolver<string>,
|
|
15
27
|
) {}
|
|
16
28
|
|
|
17
29
|
async decide(): Promise<ClaudePlannerDecision> {
|
|
18
|
-
const [hasOAuth, hasWeb, hasCli] = await Promise.all([
|
|
19
|
-
this.oauthResolver.isAvailable(),
|
|
20
|
-
this.webResolver.isAvailable(),
|
|
21
|
-
this.cliResolver.isAvailable(),
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
30
|
const selected = this.config.dataSource;
|
|
25
31
|
const webExtras = this.config.webExtrasEnabled;
|
|
26
32
|
|
|
27
33
|
if (selected === "oauth") {
|
|
28
|
-
|
|
34
|
+
const oauth = await this.oauthResolver.resolve();
|
|
35
|
+
if (!oauth) return this.decision([], webExtras);
|
|
36
|
+
const cachedWeb = await this.webResolver.resolveCached();
|
|
37
|
+
const routes: ClaudePlannedRoute[] = [{ type: "oauth", credentials: oauth }];
|
|
38
|
+
if (oauth.owner !== "environment") {
|
|
39
|
+
const cli = await this.cliResolver.resolve();
|
|
40
|
+
if (cli) routes.push({ type: "cli", credentials: cli });
|
|
41
|
+
}
|
|
42
|
+
return this.decision(routes, webExtras, cachedWeb ?? undefined);
|
|
29
43
|
}
|
|
30
44
|
if (selected === "web") {
|
|
31
|
-
|
|
45
|
+
const cachedWeb = await this.webResolver.resolveCached();
|
|
46
|
+
if (!cachedWeb && !(await this.webResolver.hasImportSources())) {
|
|
47
|
+
return this.decision([], webExtras);
|
|
48
|
+
}
|
|
49
|
+
const routes: ClaudePlannedRoute[] = [{ type: "web", credentials: cachedWeb ?? this.deferredWebSession() }];
|
|
50
|
+
return this.decision(routes, webExtras, cachedWeb ?? undefined);
|
|
32
51
|
}
|
|
33
52
|
if (selected === "cli") {
|
|
34
|
-
|
|
53
|
+
const cli = await this.cliResolver.resolve();
|
|
54
|
+
const cachedWeb = webExtras ? await this.webResolver.resolveCached() : null;
|
|
55
|
+
const routes: ClaudePlannedRoute[] = cli ? [{ type: "cli", credentials: cli }] : [];
|
|
56
|
+
return this.decision(routes, webExtras, cachedWeb ?? undefined);
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const [oauth, cli] = await Promise.all([
|
|
60
|
+
this.oauthResolver.resolve(),
|
|
61
|
+
this.cliResolver.resolve(),
|
|
62
|
+
]);
|
|
63
|
+
const cachedWeb = await this.webResolver.resolveCached();
|
|
64
|
+
const routes: ClaudePlannedRoute[] = [];
|
|
65
|
+
const webMayBeAvailable = Boolean(cachedWeb) || (await this.webResolver.hasImportSources());
|
|
66
|
+
if (oauth) {
|
|
67
|
+
routes.push({ type: "oauth", credentials: oauth });
|
|
68
|
+
if (oauth.owner !== "environment") {
|
|
69
|
+
if (cli) routes.push({ type: "cli", credentials: cli });
|
|
70
|
+
if (webMayBeAvailable) routes.push({ type: "web", credentials: cachedWeb ?? this.deferredWebSession() });
|
|
71
|
+
}
|
|
72
|
+
} else if (webMayBeAvailable) {
|
|
73
|
+
routes.push({ type: "web", credentials: cachedWeb ?? this.deferredWebSession() });
|
|
74
|
+
if (cli) routes.push({ type: "cli", credentials: cli });
|
|
75
|
+
} else if (cli) {
|
|
76
|
+
routes.push({ type: "cli", credentials: cli });
|
|
50
77
|
}
|
|
78
|
+
return this.decision(routes, webExtras, cachedWeb ?? undefined);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A planning-safe Web session without a cookie header. The first cursor
|
|
83
|
+
* advance resolves the session once and returns its imported header;
|
|
84
|
+
* later advances continue the same cursor so a rejected source is not
|
|
85
|
+
* re-imported within the attempt.
|
|
86
|
+
*/
|
|
87
|
+
private deferredWebSession(): ClaudeWebSession {
|
|
88
|
+
let sessionPromise: Promise<ClaudeWebSession | null> | null = null;
|
|
89
|
+
let primed = false;
|
|
90
|
+
return {
|
|
91
|
+
cookieHeader: "",
|
|
92
|
+
importNext: async () => {
|
|
93
|
+
sessionPromise ??= this.webResolver.resolve();
|
|
94
|
+
const session = await sessionPromise;
|
|
95
|
+
if (!session) return null;
|
|
96
|
+
if (!primed) {
|
|
97
|
+
primed = true;
|
|
98
|
+
return session.cookieHeader || null;
|
|
99
|
+
}
|
|
100
|
+
return session.importNext ? await session.importNext() : null;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
51
104
|
|
|
52
|
-
|
|
105
|
+
private decision(
|
|
106
|
+
routes: ClaudePlannedRoute[],
|
|
107
|
+
webExtras: boolean,
|
|
108
|
+
webExtrasCredentials?: ClaudeWebSession,
|
|
109
|
+
): ClaudePlannerDecision {
|
|
110
|
+
return {
|
|
111
|
+
primary: routes[0]?.type ?? "auto",
|
|
112
|
+
fallback: routes.slice(1).map((route) => route.type),
|
|
113
|
+
webExtras,
|
|
114
|
+
routes,
|
|
115
|
+
webExtrasCredentials: webExtras ? webExtrasCredentials : undefined,
|
|
116
|
+
};
|
|
53
117
|
}
|
|
54
118
|
}
|
|
@@ -1,113 +1,233 @@
|
|
|
1
1
|
import type { UsageClient } from "../client";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import {
|
|
3
|
+
AnthropicOAuthUsageClient,
|
|
4
|
+
fetchOAuthProfile,
|
|
5
|
+
} from "../clients/anthropic-oauth-usage";
|
|
6
|
+
import { InvalidCredentialsError, AnthropicWebUsageClient } from "../clients/anthropic-web-usage";
|
|
6
7
|
import { ClaudeCliProbeClient } from "../clients/claude-cli-probe";
|
|
7
|
-
import
|
|
8
|
+
import { classifyUsageError, UsageError } from "../errors";
|
|
8
9
|
import { mapClaudeToEntry } from "../mappers";
|
|
10
|
+
import type { ClaudeSourcePlanner } from "../planner";
|
|
11
|
+
import type { TokenResolver } from "../resolver";
|
|
12
|
+
import type {
|
|
13
|
+
ClaudeOAuthCredentials,
|
|
14
|
+
ClaudePlannedRoute,
|
|
15
|
+
ClaudeUsageSnapshot,
|
|
16
|
+
ClaudeWebSession,
|
|
17
|
+
UsageProviderEntry,
|
|
18
|
+
} from "../types";
|
|
19
|
+
|
|
20
|
+
interface ClaudeClients {
|
|
21
|
+
oauth: UsageClient<ClaudeOAuthCredentials, ClaudeUsageSnapshot>;
|
|
22
|
+
web: UsageClient<ClaudeWebSession, ClaudeUsageSnapshot>;
|
|
23
|
+
cli: UsageClient<string, ClaudeUsageSnapshot>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface WebExtrasPrimaryIdentity {
|
|
27
|
+
email: string | null;
|
|
28
|
+
organizationId: string | null;
|
|
29
|
+
organizationName: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface KeychainDiagnostics {
|
|
33
|
+
describeKeychainFailure?(): string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_PROFILE_IDENTITY_TTL_MS = 30 * 60 * 1_000;
|
|
37
|
+
|
|
38
|
+
function nullIdentity(): WebExtrasPrimaryIdentity {
|
|
39
|
+
return { email: null, organizationId: null, organizationName: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizedIdentity(value: string | null | undefined): string | null {
|
|
43
|
+
const trimmed = value?.trim().toLowerCase();
|
|
44
|
+
return trimmed ? trimmed : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function webExtrasIdentityMatches(primary: WebExtrasPrimaryIdentity, web: ClaudeUsageSnapshot): boolean {
|
|
48
|
+
const webEmail = normalizedIdentity(web.accountEmail);
|
|
49
|
+
if (primary.email && webEmail && normalizedIdentity(primary.email) === webEmail) return true;
|
|
50
|
+
const webOrganizationId = normalizedIdentity(web.accountOrganizationId);
|
|
51
|
+
if (primary.organizationId && webOrganizationId && normalizedIdentity(primary.organizationId) === webOrganizationId) return true;
|
|
52
|
+
const webOrganizationName = normalizedIdentity(web.accountOrganization);
|
|
53
|
+
if (primary.organizationName && webOrganizationName && normalizedIdentity(primary.organizationName) === webOrganizationName) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
9
56
|
|
|
10
57
|
export class ClaudeUsageProvider {
|
|
11
|
-
private readonly
|
|
12
|
-
private readonly
|
|
13
|
-
private readonly
|
|
58
|
+
private readonly clients: ClaudeClients;
|
|
59
|
+
private readonly oauthResolver: TokenResolver<ClaudeOAuthCredentials> & KeychainDiagnostics;
|
|
60
|
+
private readonly webResolver: TokenResolver<ClaudeWebSession> & KeychainDiagnostics;
|
|
61
|
+
private readonly profileIdentityTtlMs: number;
|
|
62
|
+
private profileIdentity?: { token: string; identity: WebExtrasPrimaryIdentity; fetchedAt: number };
|
|
14
63
|
|
|
15
64
|
constructor(
|
|
16
65
|
private readonly planner: ClaudeSourcePlanner,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
66
|
+
oauthResolver: TokenResolver<ClaudeOAuthCredentials> & KeychainDiagnostics,
|
|
67
|
+
webResolver: TokenResolver<ClaudeWebSession> & KeychainDiagnostics,
|
|
68
|
+
_cliResolver: TokenResolver<string>,
|
|
69
|
+
clients?: Partial<ClaudeClients>,
|
|
70
|
+
options?: { profileIdentityTtlMs?: number },
|
|
20
71
|
) {
|
|
21
|
-
this.
|
|
22
|
-
this.
|
|
23
|
-
this.
|
|
72
|
+
this.oauthResolver = oauthResolver;
|
|
73
|
+
this.webResolver = webResolver;
|
|
74
|
+
this.profileIdentityTtlMs = options?.profileIdentityTtlMs ?? DEFAULT_PROFILE_IDENTITY_TTL_MS;
|
|
75
|
+
this.clients = {
|
|
76
|
+
oauth: clients?.oauth ?? new AnthropicOAuthUsageClient(),
|
|
77
|
+
web: clients?.web ?? new AnthropicWebUsageClient(),
|
|
78
|
+
cli: clients?.cli ?? new ClaudeCliProbeClient(),
|
|
79
|
+
};
|
|
24
80
|
}
|
|
25
81
|
|
|
26
82
|
async fetch(options?: { signal?: AbortSignal }): Promise<{ entry: UsageProviderEntry; snapshot: ClaudeUsageSnapshot | null }> {
|
|
27
83
|
const decision = await this.planner.decide();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
84
|
+
if (decision.routes.length === 0) {
|
|
85
|
+
// Surface actionable keychain diagnostics when a blocking failure (not
|
|
86
|
+
// merely missing credentials) explains the empty route plan.
|
|
87
|
+
const reason =
|
|
88
|
+
this.oauthResolver.describeKeychainFailure?.() ??
|
|
89
|
+
this.webResolver.describeKeychainFailure?.() ??
|
|
90
|
+
null;
|
|
91
|
+
return { entry: mapClaudeToEntry(null, reason ? [reason] : [], false, null), snapshot: null };
|
|
33
92
|
}
|
|
34
93
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
94
|
+
const errors: string[] = [];
|
|
95
|
+
let sawNonAuthFailure = false;
|
|
96
|
+
for (let index = 0; index < decision.routes.length; index++) {
|
|
97
|
+
const route = decision.routes[index]!;
|
|
98
|
+
try {
|
|
99
|
+
const snapshot = await this.fetchRoute(route, options);
|
|
100
|
+
const withExtras = await this.mergeWebExtras(snapshot, route, decision.webExtrasCredentials, options);
|
|
101
|
+
const winningOAuth = route.type === "oauth" ? route.credentials : null;
|
|
102
|
+
return { entry: mapClaudeToEntry(withExtras, [], true, winningOAuth), snapshot: withExtras };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const classified = error instanceof InvalidCredentialsError
|
|
105
|
+
? new UsageError("invalid-credentials", error.message)
|
|
106
|
+
: classifyUsageError(error);
|
|
107
|
+
if (classified.kind === "cancelled") throw classified;
|
|
108
|
+
errors.push(`${route.type}: ${classified.message}`);
|
|
109
|
+
if (classified.kind !== "invalid-credentials") sawNonAuthFailure = true;
|
|
110
|
+
if (classified.kind === "invalid-credentials" && route.type === "oauth") {
|
|
111
|
+
// Invalidate the daemon-lifetime credential cache so the next poll
|
|
112
|
+
// re-resolves the OAuth source after an authentication rejection.
|
|
113
|
+
// The profile identity cache is keyed by token, so dropping it
|
|
114
|
+
// forces re-verification once the rotated token arrives.
|
|
115
|
+
(this.oauthResolver as Partial<{ invalidate?: () => void }>).invalidate?.();
|
|
116
|
+
this.profileIdentity = undefined;
|
|
117
|
+
}
|
|
118
|
+
const canRecover = classified.kind === "invalid-credentials" && index + 1 < decision.routes.length;
|
|
119
|
+
if (!canRecover) break;
|
|
120
|
+
}
|
|
51
121
|
}
|
|
52
122
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
123
|
+
if (errors.length > 0 && !sawNonAuthFailure) {
|
|
124
|
+
// Every attempted route failed with authentication failures: surface a
|
|
125
|
+
// single actionable state instead of raw probe errors.
|
|
126
|
+
const sessionExpired = errors.some((message) => message.includes("expired") || message.includes("re-login"));
|
|
127
|
+
if (sessionExpired) {
|
|
128
|
+
return {
|
|
129
|
+
entry: mapClaudeToEntry(null, ["Claude session expired — run \"claude\" to re-login"], false, null),
|
|
130
|
+
snapshot: null,
|
|
131
|
+
};
|
|
61
132
|
}
|
|
133
|
+
return { entry: mapClaudeToEntry(null, [], false, null), snapshot: null };
|
|
62
134
|
}
|
|
63
135
|
|
|
64
|
-
|
|
136
|
+
return { entry: mapClaudeToEntry(null, errors, true, null), snapshot: null };
|
|
137
|
+
}
|
|
65
138
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
snapshot = await this.cliClient.fetch(source.creds as string, options);
|
|
74
|
-
}
|
|
75
|
-
break;
|
|
76
|
-
} catch (err) {
|
|
77
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
78
|
-
errors.push(`${source.type}: ${message}`);
|
|
79
|
-
if (err instanceof InvalidCredentialsError || err instanceof AuthenticationError || err instanceof ScopeError) {
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
// Continue to fallback for other errors too
|
|
139
|
+
private fetchRoute(
|
|
140
|
+
route: ClaudePlannedRoute,
|
|
141
|
+
options?: { signal?: AbortSignal },
|
|
142
|
+
): Promise<ClaudeUsageSnapshot> {
|
|
143
|
+
if (route.type === "oauth") {
|
|
144
|
+
if (route.credentials.isStale) {
|
|
145
|
+
return Promise.reject(new UsageError("invalid-credentials", "Claude-owned OAuth token is expired or near expiry"));
|
|
83
146
|
}
|
|
147
|
+
return this.clients.oauth.fetch(route.credentials, options);
|
|
84
148
|
}
|
|
149
|
+
if (route.type === "web") return this.fetchWebRoute(route.credentials, options);
|
|
150
|
+
return this.clients.cli.fetch(route.credentials, options);
|
|
151
|
+
}
|
|
85
152
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
153
|
+
private async fetchWebRoute(
|
|
154
|
+
session: ClaudeWebSession,
|
|
155
|
+
options?: { signal?: AbortSignal },
|
|
156
|
+
): Promise<ClaudeUsageSnapshot> {
|
|
157
|
+
let cookieHeader = session.cookieHeader;
|
|
158
|
+
if (!cookieHeader && session.importNext) {
|
|
159
|
+
cookieHeader = (await session.importNext()) ?? "";
|
|
160
|
+
}
|
|
161
|
+
if (!cookieHeader) {
|
|
162
|
+
// Prefer the resolver's keychain diagnostic when a blocking failure (not
|
|
163
|
+
// a transient import miss) is why no web session could be produced. It
|
|
164
|
+
// is an environment failure, not rejected credentials, so transient
|
|
165
|
+
// classification keeps the reason visible in the reported entry.
|
|
166
|
+
const reason = this.webResolver.describeKeychainFailure?.();
|
|
167
|
+
throw new UsageError(
|
|
168
|
+
reason ? "transient" : "invalid-credentials",
|
|
169
|
+
reason
|
|
170
|
+
? `${reason} (no Claude web session available)`
|
|
171
|
+
: "No Claude web session available",
|
|
172
|
+
);
|
|
94
173
|
}
|
|
174
|
+
return this.clients.web.fetch({ ...session, cookieHeader }, options);
|
|
175
|
+
}
|
|
95
176
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
177
|
+
private async mergeWebExtras(
|
|
178
|
+
snapshot: ClaudeUsageSnapshot,
|
|
179
|
+
route: ClaudePlannedRoute,
|
|
180
|
+
webCredentials: ClaudeWebSession | undefined,
|
|
181
|
+
options?: { signal?: AbortSignal },
|
|
182
|
+
): Promise<ClaudeUsageSnapshot> {
|
|
183
|
+
if (!webCredentials || snapshot.source === "web") return snapshot;
|
|
184
|
+
try {
|
|
185
|
+
const web = await this.clients.web.fetch(webCredentials, options);
|
|
186
|
+
const primary = await this.primaryWebExtrasIdentity(snapshot, route, options);
|
|
187
|
+
if (!webExtrasIdentityMatches(primary, web)) return snapshot;
|
|
188
|
+
return {
|
|
189
|
+
...snapshot,
|
|
190
|
+
extraRateWindows: snapshot.extraRateWindows.length === 0 ? web.extraRateWindows : snapshot.extraRateWindows,
|
|
191
|
+
providerCost: snapshot.providerCost ?? web.providerCost,
|
|
192
|
+
};
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (classifyUsageError(error).kind === "cancelled") throw error;
|
|
195
|
+
return snapshot;
|
|
109
196
|
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async primaryWebExtrasIdentity(
|
|
200
|
+
snapshot: ClaudeUsageSnapshot,
|
|
201
|
+
route: ClaudePlannedRoute,
|
|
202
|
+
options?: { signal?: AbortSignal },
|
|
203
|
+
): Promise<WebExtrasPrimaryIdentity> {
|
|
204
|
+
if (snapshot.accountEmail || snapshot.accountOrganization || snapshot.accountOrganizationId) {
|
|
205
|
+
return {
|
|
206
|
+
email: snapshot.accountEmail,
|
|
207
|
+
organizationId: snapshot.accountOrganizationId,
|
|
208
|
+
organizationName: snapshot.accountOrganization,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (route.type !== "oauth") return nullIdentity();
|
|
110
212
|
|
|
111
|
-
|
|
213
|
+
const token = route.credentials.accessToken;
|
|
214
|
+
const cached = this.profileIdentity;
|
|
215
|
+
if (cached && cached.token === token && Date.now() - cached.fetchedAt < this.profileIdentityTtlMs) {
|
|
216
|
+
return cached.identity;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const profile = await fetchOAuthProfile(token, options);
|
|
220
|
+
const identity: WebExtrasPrimaryIdentity = {
|
|
221
|
+
email: profile.email,
|
|
222
|
+
organizationId: profile.organizationId,
|
|
223
|
+
organizationName: null,
|
|
224
|
+
};
|
|
225
|
+
this.profileIdentity = { token, identity, fetchedAt: Date.now() };
|
|
226
|
+
return identity;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (classifyUsageError(error).kind === "cancelled") throw error;
|
|
229
|
+
// Best-effort: do not cache failures so the next poll can retry.
|
|
230
|
+
return nullIdentity();
|
|
231
|
+
}
|
|
112
232
|
}
|
|
113
233
|
}
|
|
@@ -1,34 +1,115 @@
|
|
|
1
1
|
import type { UsageClient } from "../client";
|
|
2
|
-
import type { CodexOAuthCredentials, CodexUsageSnapshot, UsageProviderEntry } from "../types";
|
|
2
|
+
import type { CodexDataSource, CodexOAuthCredentials, CodexUsageSnapshot, UsageProviderEntry } from "../types";
|
|
3
3
|
import type { TokenResolver } from "../resolver";
|
|
4
|
-
import { CodexOAuthUsageClient } from "../clients/codex-oauth-usage";
|
|
4
|
+
import { CodexMonthlySpendClient, CodexOAuthUsageClient } from "../clients/codex-oauth-usage";
|
|
5
|
+
import { CodexAppServerClient } from "../clients/codex-app-server";
|
|
6
|
+
import { classifyUsageError, isRecoverableAuthenticationError } from "../errors";
|
|
7
|
+
import { isCodexCredentialFresh } from "../resolvers/codex-auth";
|
|
5
8
|
import { mapCodexToEntry } from "../mappers";
|
|
6
9
|
|
|
10
|
+
interface CodexAppServerUsageClient {
|
|
11
|
+
fetch(
|
|
12
|
+
identity?: Pick<CodexOAuthCredentials, "accountEmail" | "accountId">,
|
|
13
|
+
options?: { signal?: AbortSignal },
|
|
14
|
+
): Promise<CodexUsageSnapshot>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CodexMonthlySpendUsageClient {
|
|
18
|
+
fetch(
|
|
19
|
+
credentials: CodexOAuthCredentials,
|
|
20
|
+
accountId: string,
|
|
21
|
+
options?: { signal?: AbortSignal },
|
|
22
|
+
): Promise<NonNullable<CodexUsageSnapshot["individualLimit"]> | null>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CodexUsageProviderDependencies {
|
|
26
|
+
oauthClient?: UsageClient<CodexOAuthCredentials, CodexUsageSnapshot>;
|
|
27
|
+
appServerClient?: CodexAppServerUsageClient;
|
|
28
|
+
monthlySpendClient?: CodexMonthlySpendUsageClient;
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
export class CodexUsageProvider {
|
|
8
32
|
private readonly oauthClient: UsageClient<CodexOAuthCredentials, CodexUsageSnapshot>;
|
|
33
|
+
private readonly appServerClient: CodexAppServerUsageClient;
|
|
34
|
+
private readonly monthlySpendClient: CodexMonthlySpendUsageClient;
|
|
9
35
|
|
|
10
36
|
constructor(
|
|
11
37
|
private readonly oauthResolver: TokenResolver<CodexOAuthCredentials>,
|
|
38
|
+
private readonly dataSource: CodexDataSource = "auto",
|
|
39
|
+
dependencies: CodexUsageProviderDependencies = {},
|
|
12
40
|
) {
|
|
13
|
-
this.oauthClient = new CodexOAuthUsageClient();
|
|
41
|
+
this.oauthClient = dependencies.oauthClient ?? new CodexOAuthUsageClient();
|
|
42
|
+
this.appServerClient = dependencies.appServerClient ?? new CodexAppServerClient();
|
|
43
|
+
this.monthlySpendClient = dependencies.monthlySpendClient ?? new CodexMonthlySpendClient();
|
|
14
44
|
}
|
|
15
45
|
|
|
16
46
|
async fetch(options?: { signal?: AbortSignal }): Promise<{ entry: UsageProviderEntry; snapshot: CodexUsageSnapshot | null }> {
|
|
17
|
-
|
|
18
|
-
|
|
47
|
+
let oauthCreds: CodexOAuthCredentials | null;
|
|
48
|
+
try {
|
|
49
|
+
oauthCreds = await this.oauthResolver.resolve();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const failure = classifyUsageError(error);
|
|
52
|
+
return { entry: mapCodexToEntry(null, [`oauth: ${failure.message}`], true, null), snapshot: null };
|
|
53
|
+
}
|
|
19
54
|
|
|
20
55
|
if (!oauthCreds) {
|
|
21
56
|
return { entry: mapCodexToEntry(null, [], false, null), snapshot: null };
|
|
22
57
|
}
|
|
23
58
|
|
|
24
|
-
|
|
59
|
+
if (!isCodexCredentialFresh(oauthCreds)) {
|
|
60
|
+
return this.fetchFromCli(oauthCreds, options);
|
|
61
|
+
}
|
|
62
|
+
|
|
25
63
|
try {
|
|
26
|
-
snapshot = await this.oauthClient.fetch(oauthCreds, options);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
64
|
+
let snapshot = await this.oauthClient.fetch(oauthCreds, options);
|
|
65
|
+
snapshot = await this.enrichMonthlySpend(snapshot, oauthCreds, options);
|
|
66
|
+
return { entry: mapCodexToEntry(snapshot, [], true, oauthCreds), snapshot };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const failure = classifyUsageError(error);
|
|
69
|
+
if (failure.kind === "cancelled") throw failure;
|
|
70
|
+
if (this.dataSource === "auto" && isRecoverableAuthenticationError(failure)) {
|
|
71
|
+
return this.fetchFromCli(oauthCreds, options);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
entry: mapCodexToEntry(null, [`oauth: ${failure.message}`], true, oauthCreds),
|
|
75
|
+
snapshot: null,
|
|
76
|
+
};
|
|
30
77
|
}
|
|
78
|
+
}
|
|
31
79
|
|
|
32
|
-
|
|
80
|
+
private async enrichMonthlySpend(
|
|
81
|
+
snapshot: CodexUsageSnapshot,
|
|
82
|
+
oauthCreds: CodexOAuthCredentials,
|
|
83
|
+
options?: { signal?: AbortSignal },
|
|
84
|
+
): Promise<CodexUsageSnapshot> {
|
|
85
|
+
const accountId = snapshot.accountId ?? oauthCreds.accountId;
|
|
86
|
+
if (snapshot.source !== "oauth" || !snapshot.spendControlPresent || snapshot.individualLimit || !accountId) {
|
|
87
|
+
return snapshot;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const individualLimit = await this.monthlySpendClient.fetch(oauthCreds, accountId, options);
|
|
91
|
+
return individualLimit ? { ...snapshot, individualLimit } : snapshot;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const failure = classifyUsageError(error);
|
|
94
|
+
if (failure.kind === "cancelled") throw failure;
|
|
95
|
+
return snapshot;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async fetchFromCli(
|
|
100
|
+
oauthCreds: CodexOAuthCredentials,
|
|
101
|
+
options?: { signal?: AbortSignal },
|
|
102
|
+
): Promise<{ entry: UsageProviderEntry; snapshot: CodexUsageSnapshot | null }> {
|
|
103
|
+
try {
|
|
104
|
+
const snapshot = await this.appServerClient.fetch(oauthCreds, options);
|
|
105
|
+
return { entry: mapCodexToEntry(snapshot, [], true, null), snapshot };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const failure = classifyUsageError(error);
|
|
108
|
+
if (failure.kind === "cancelled") throw failure;
|
|
109
|
+
return {
|
|
110
|
+
entry: mapCodexToEntry(null, [`cli: ${failure.message}`], true, oauthCreds),
|
|
111
|
+
snapshot: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
33
114
|
}
|
|
34
115
|
}
|