@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
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { UsageClient } from "../client";
|
|
2
2
|
import type { ClaudeUsageSnapshot, NamedRateWindowLegacy, ProviderCostSnapshotLegacy, RateWindowLegacy, ClaudeWebSession } from "../types";
|
|
3
|
+
import { classifyUsageError, UsageError } from "../errors";
|
|
4
|
+
import { usageWebSessionCache } from "../web-session-cache";
|
|
5
|
+
import { CLAUDE_WEB_CACHE_KEY } from "../resolvers/claude-web";
|
|
3
6
|
|
|
4
|
-
export class InvalidCredentialsError extends
|
|
7
|
+
export class InvalidCredentialsError extends UsageError {
|
|
5
8
|
constructor(message: string) {
|
|
6
|
-
super(message);
|
|
9
|
+
super("invalid-credentials", message);
|
|
7
10
|
this.name = "InvalidCredentialsError";
|
|
8
11
|
}
|
|
9
12
|
}
|
|
@@ -63,10 +66,12 @@ const ROUTINES_ALTERNATES = [
|
|
|
63
66
|
"cowork",
|
|
64
67
|
];
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
const OAUTH_APPS_ALTERNATES = ["seven_day_oauth_apps", "oauth_apps"];
|
|
70
|
+
|
|
71
|
+
function pickWindowData(data: UsageResponse, keys: string[]): { utilization?: number | null; resets_at?: string | null } | null {
|
|
67
72
|
for (const key of keys) {
|
|
68
73
|
const val = data[key];
|
|
69
|
-
if (val && typeof val === "object"
|
|
74
|
+
if (val && typeof val === "object") {
|
|
70
75
|
return val as { utilization?: number | null; resets_at?: string | null };
|
|
71
76
|
}
|
|
72
77
|
}
|
|
@@ -93,25 +98,81 @@ function toRateWindow(
|
|
|
93
98
|
|
|
94
99
|
export class AnthropicWebUsageClient implements UsageClient<ClaudeWebSession, ClaudeUsageSnapshot> {
|
|
95
100
|
async fetch(creds: ClaudeWebSession, options?: { signal?: AbortSignal }): Promise<ClaudeUsageSnapshot> {
|
|
101
|
+
const candidates: string[] = [...new Set(creds.cookieCandidates ?? (creds.cookieHeader ? [creds.cookieHeader] : []))].slice(0, 2);
|
|
102
|
+
let expectedRevision = creds.cacheRevision ?? usageWebSessionCache.observe(CLAUDE_WEB_CACHE_KEY).revision;
|
|
103
|
+
|
|
104
|
+
for (let index = 0; index < candidates.length; index++) {
|
|
105
|
+
let rotated = false;
|
|
106
|
+
try {
|
|
107
|
+
const snapshot = await this.fetchWithCookie(candidates[index]!, options?.signal, (cookieHeader) => {
|
|
108
|
+
const stored = usageWebSessionCache.store(CLAUDE_WEB_CACHE_KEY, cookieHeader, expectedRevision);
|
|
109
|
+
if (stored) expectedRevision = stored.revision;
|
|
110
|
+
rotated = true;
|
|
111
|
+
});
|
|
112
|
+
if (!rotated) {
|
|
113
|
+
const stored = usageWebSessionCache.store(CLAUDE_WEB_CACHE_KEY, candidates[index]!, expectedRevision);
|
|
114
|
+
if (stored) expectedRevision = stored.revision;
|
|
115
|
+
}
|
|
116
|
+
return snapshot;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const failure = classifyUsageError(error);
|
|
119
|
+
if (failure.kind === "invalid-credentials") {
|
|
120
|
+
if (usageWebSessionCache.clear(CLAUDE_WEB_CACHE_KEY, expectedRevision)) expectedRevision = 0;
|
|
121
|
+
if (index + 1 < candidates.length) continue;
|
|
122
|
+
// Advance the demand-driven cursor at most once per attempt, keeping
|
|
123
|
+
// the two-candidate bound across imported and rotated candidates.
|
|
124
|
+
const imported = candidates.length < 2 ? (await creds.importNext?.()) ?? null : null;
|
|
125
|
+
if (imported && !candidates.includes(imported)) {
|
|
126
|
+
candidates.push(imported);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw failure;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new InvalidCredentialsError("Session cookie invalid or expired");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async fetchWithCookie(
|
|
137
|
+
initialCookieHeader: string,
|
|
138
|
+
signal: AbortSignal | undefined,
|
|
139
|
+
onCookieRotation: (cookieHeader: string) => void,
|
|
140
|
+
): Promise<ClaudeUsageSnapshot> {
|
|
141
|
+
let cookieHeader = initialCookieHeader;
|
|
142
|
+
const request = async (url: string): Promise<Response> => {
|
|
143
|
+
let response: Response;
|
|
144
|
+
try {
|
|
145
|
+
response = await fetch(url, {
|
|
146
|
+
signal,
|
|
147
|
+
headers: { Cookie: cookieHeader, Accept: "application/json" },
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw classifyUsageError(error);
|
|
151
|
+
}
|
|
152
|
+
const rotated = applyClaudeSessionRotation(cookieHeader, response.headers);
|
|
153
|
+
if (rotated !== cookieHeader) {
|
|
154
|
+
cookieHeader = rotated;
|
|
155
|
+
onCookieRotation(cookieHeader);
|
|
156
|
+
}
|
|
157
|
+
return response;
|
|
158
|
+
};
|
|
159
|
+
|
|
96
160
|
// 1. Fetch organizations
|
|
97
|
-
const orgRes = await
|
|
98
|
-
signal: options?.signal,
|
|
99
|
-
headers: {
|
|
100
|
-
Cookie: creds.cookieHeader,
|
|
101
|
-
Accept: "application/json",
|
|
102
|
-
},
|
|
103
|
-
});
|
|
161
|
+
const orgRes = await request("https://claude.ai/api/organizations");
|
|
104
162
|
|
|
105
|
-
if (orgRes.status === 401
|
|
163
|
+
if (orgRes.status === 401) {
|
|
106
164
|
throw new InvalidCredentialsError("Session cookie invalid or expired");
|
|
107
165
|
}
|
|
166
|
+
if (orgRes.status === 403) throw new UsageError("forbidden", "Organizations access forbidden");
|
|
167
|
+
if (orgRes.status === 429) throw new UsageError("rate-limited", "Organizations fetch rate limited");
|
|
168
|
+
if (orgRes.status >= 500) throw new UsageError("transient", `Organizations fetch failed: ${orgRes.status}`);
|
|
108
169
|
if (!orgRes.ok) {
|
|
109
|
-
throw new
|
|
170
|
+
throw new UsageError("invalid-response", `Organizations fetch failed: ${orgRes.status}`);
|
|
110
171
|
}
|
|
111
172
|
|
|
112
173
|
const orgs = (await orgRes.json()) as Org[];
|
|
113
174
|
if (!Array.isArray(orgs) || orgs.length === 0) {
|
|
114
|
-
throw new
|
|
175
|
+
throw new UsageError("invalid-response", "No organizations found");
|
|
115
176
|
}
|
|
116
177
|
|
|
117
178
|
// Select org
|
|
@@ -124,37 +185,42 @@ export class AnthropicWebUsageClient implements UsageClient<ClaudeWebSession, Cl
|
|
|
124
185
|
}
|
|
125
186
|
|
|
126
187
|
// 2. Fetch usage
|
|
127
|
-
const usageRes = await
|
|
128
|
-
signal: options?.signal,
|
|
129
|
-
headers: {
|
|
130
|
-
Cookie: creds.cookieHeader,
|
|
131
|
-
Accept: "application/json",
|
|
132
|
-
},
|
|
133
|
-
});
|
|
188
|
+
const usageRes = await request(`https://claude.ai/api/organizations/${org.uuid}/usage`);
|
|
134
189
|
|
|
135
|
-
if (usageRes.status === 401
|
|
190
|
+
if (usageRes.status === 401) {
|
|
136
191
|
throw new InvalidCredentialsError("Session cookie invalid or expired");
|
|
137
192
|
}
|
|
193
|
+
if (usageRes.status === 403) throw new UsageError("forbidden", "Usage access forbidden");
|
|
194
|
+
if (usageRes.status === 429) throw new UsageError("rate-limited", "Usage fetch rate limited");
|
|
195
|
+
if (usageRes.status >= 500) throw new UsageError("transient", `Usage fetch failed: ${usageRes.status}`);
|
|
138
196
|
if (!usageRes.ok) {
|
|
139
|
-
throw new
|
|
197
|
+
throw new UsageError("invalid-response", `Usage fetch failed: ${usageRes.status}`);
|
|
140
198
|
}
|
|
141
199
|
|
|
142
200
|
const data = (await usageRes.json()) as UsageResponse;
|
|
143
201
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
202
|
+
// Null or missing windows mean "no usage recorded for the period" — a
|
|
203
|
+
// valid response, presented as an empty window at 0% (matches the
|
|
204
|
+
// reference implementation's "100% left" presentation).
|
|
205
|
+
const primaryWindow = pickWindowData(data, ["five_hour"]);
|
|
206
|
+
const secondaryWindow = pickWindowData(data, ["seven_day"]);
|
|
147
207
|
|
|
148
|
-
const primary = toRateWindow(
|
|
149
|
-
|
|
150
|
-
|
|
208
|
+
const primary = toRateWindow(
|
|
209
|
+
primaryWindow && typeof primaryWindow.utilization === "number" ? primaryWindow.utilization : 0,
|
|
210
|
+
primaryWindow?.resets_at ?? null,
|
|
211
|
+
300,
|
|
212
|
+
);
|
|
213
|
+
const secondary = secondaryWindow && typeof secondaryWindow.utilization === "number"
|
|
214
|
+
? toRateWindow(secondaryWindow.utilization, secondaryWindow.resets_at, 10080)
|
|
151
215
|
: null;
|
|
152
216
|
|
|
153
|
-
const opusWindow =
|
|
154
|
-
const opus = opusWindow
|
|
217
|
+
const opusWindow = pickWindowData(data, ["seven_day_sonnet", "seven_day_opus"]);
|
|
218
|
+
const opus = opusWindow && typeof opusWindow.utilization === "number"
|
|
219
|
+
? toRateWindow(opusWindow.utilization, opusWindow.resets_at, 10080)
|
|
220
|
+
: null;
|
|
155
221
|
|
|
156
222
|
const extraRateWindows: NamedRateWindowLegacy[] = [];
|
|
157
|
-
const designWindow =
|
|
223
|
+
const designWindow = pickWindowData(data, DESIGN_ALTERNATES);
|
|
158
224
|
if (designWindow && typeof designWindow.utilization === "number") {
|
|
159
225
|
extraRateWindows.push({
|
|
160
226
|
id: "claude-design",
|
|
@@ -162,7 +228,7 @@ export class AnthropicWebUsageClient implements UsageClient<ClaudeWebSession, Cl
|
|
|
162
228
|
window: toRateWindow(designWindow.utilization, designWindow.resets_at, 10080),
|
|
163
229
|
});
|
|
164
230
|
}
|
|
165
|
-
const routinesWindow =
|
|
231
|
+
const routinesWindow = pickWindowData(data, ROUTINES_ALTERNATES);
|
|
166
232
|
if (routinesWindow && typeof routinesWindow.utilization === "number") {
|
|
167
233
|
extraRateWindows.push({
|
|
168
234
|
id: "claude-routines",
|
|
@@ -170,17 +236,19 @@ export class AnthropicWebUsageClient implements UsageClient<ClaudeWebSession, Cl
|
|
|
170
236
|
window: toRateWindow(routinesWindow.utilization, routinesWindow.resets_at, 10080),
|
|
171
237
|
});
|
|
172
238
|
}
|
|
239
|
+
const oauthAppsWindow = pickWindowData(data, OAUTH_APPS_ALTERNATES);
|
|
240
|
+
if (oauthAppsWindow && typeof oauthAppsWindow.utilization === "number") {
|
|
241
|
+
extraRateWindows.push({
|
|
242
|
+
id: "claude-oauth-apps",
|
|
243
|
+
title: "OAuth Apps",
|
|
244
|
+
window: toRateWindow(oauthAppsWindow.utilization, oauthAppsWindow.resets_at, 10080),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
173
247
|
|
|
174
248
|
// 3. Best-effort overage
|
|
175
249
|
let providerCost: ProviderCostSnapshotLegacy | null = null;
|
|
176
250
|
try {
|
|
177
|
-
const overageRes = await
|
|
178
|
-
signal: options?.signal,
|
|
179
|
-
headers: {
|
|
180
|
-
Cookie: creds.cookieHeader,
|
|
181
|
-
Accept: "application/json",
|
|
182
|
-
},
|
|
183
|
-
});
|
|
251
|
+
const overageRes = await request(`https://claude.ai/api/organizations/${org.uuid}/overage_spend_limit`);
|
|
184
252
|
if (overageRes.ok) {
|
|
185
253
|
const overage = (await overageRes.json()) as OverageResponse;
|
|
186
254
|
if (overage.is_enabled) {
|
|
@@ -194,22 +262,18 @@ export class AnthropicWebUsageClient implements UsageClient<ClaudeWebSession, Cl
|
|
|
194
262
|
};
|
|
195
263
|
}
|
|
196
264
|
}
|
|
197
|
-
} catch {
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (classifyUsageError(error).kind === "cancelled") throw error;
|
|
198
267
|
// Ignore best-effort failure
|
|
199
268
|
}
|
|
200
269
|
|
|
201
270
|
// 4. Best-effort account info
|
|
202
271
|
let accountEmail: string | null = null;
|
|
203
272
|
let accountOrganization: string | null = null;
|
|
273
|
+
let accountOrganizationId: string | null = null;
|
|
204
274
|
let loginMethod: string | null = null;
|
|
205
275
|
try {
|
|
206
|
-
const accountRes = await
|
|
207
|
-
signal: options?.signal,
|
|
208
|
-
headers: {
|
|
209
|
-
Cookie: creds.cookieHeader,
|
|
210
|
-
Accept: "application/json",
|
|
211
|
-
},
|
|
212
|
-
});
|
|
276
|
+
const accountRes = await request("https://claude.ai/api/account");
|
|
213
277
|
if (accountRes.ok) {
|
|
214
278
|
const account = (await accountRes.json()) as AccountResponse;
|
|
215
279
|
if (account.email_address) {
|
|
@@ -222,8 +286,10 @@ export class AnthropicWebUsageClient implements UsageClient<ClaudeWebSession, Cl
|
|
|
222
286
|
loginMethod = membership.organization.rate_limit_tier;
|
|
223
287
|
}
|
|
224
288
|
}
|
|
289
|
+
accountOrganizationId = org.uuid;
|
|
225
290
|
}
|
|
226
|
-
} catch {
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (classifyUsageError(error).kind === "cancelled") throw error;
|
|
227
293
|
// Ignore best-effort failure
|
|
228
294
|
}
|
|
229
295
|
|
|
@@ -237,9 +303,27 @@ export class AnthropicWebUsageClient implements UsageClient<ClaudeWebSession, Cl
|
|
|
237
303
|
updatedAt: new Date(),
|
|
238
304
|
accountEmail,
|
|
239
305
|
accountOrganization,
|
|
306
|
+
accountOrganizationId,
|
|
240
307
|
loginMethod,
|
|
241
308
|
subscriptionType: null,
|
|
242
309
|
rateLimitTier: null,
|
|
243
310
|
};
|
|
244
311
|
}
|
|
245
312
|
}
|
|
313
|
+
|
|
314
|
+
function applyClaudeSessionRotation(current: string, headers: Headers): string {
|
|
315
|
+
const values = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.()
|
|
316
|
+
?? (headers.get("set-cookie") ? [headers.get("set-cookie")!] : []);
|
|
317
|
+
let sessionKey = current.split(";").map((part) => part.trim()).find((part) => part.startsWith("sessionKey="));
|
|
318
|
+
for (const value of values) {
|
|
319
|
+
const [pair, ...attributes] = value.split(";");
|
|
320
|
+
if (!pair?.trim().startsWith("sessionKey=")) continue;
|
|
321
|
+
const expired = attributes.some((attribute) => {
|
|
322
|
+
const [name, raw = ""] = attribute.trim().split("=", 2);
|
|
323
|
+
if (name?.toLowerCase() === "max-age") return Number(raw) <= 0;
|
|
324
|
+
return name?.toLowerCase() === "expires" && Date.parse(raw) <= Date.now();
|
|
325
|
+
});
|
|
326
|
+
sessionKey = expired || pair.trim() === "sessionKey=" ? undefined : pair.trim();
|
|
327
|
+
}
|
|
328
|
+
return sessionKey ?? "";
|
|
329
|
+
}
|