@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.
Files changed (97) hide show
  1. package/README.md +46 -7
  2. package/package.json +13 -10
  3. package/src/api/docs/sessions.ts +3 -2
  4. package/src/api/docs/system.ts +3 -0
  5. package/src/api/docs/usage.ts +2 -2
  6. package/src/api/routes/sessions.ts +6 -2
  7. package/src/api/routes/system.ts +4 -1
  8. package/src/api/routes/webhooks/claude.ts +9 -21
  9. package/src/api/routes/webhooks/opencode.ts +6 -1
  10. package/src/api/routes.ts +1 -1
  11. package/src/api/schemas/common.ts +8 -0
  12. package/src/api/schemas/sessions.ts +15 -1
  13. package/src/api/schemas/usage.ts +1 -0
  14. package/src/cli/commands.ts +170 -26
  15. package/src/cli/index.ts +18 -0
  16. package/src/cli/program.ts +175 -0
  17. package/src/daemon/collectors/claude/collector.ts +11 -1
  18. package/src/daemon/collectors/claude/discovery.ts +2 -23
  19. package/src/daemon/collectors/codex/collector.ts +3 -3
  20. package/src/daemon/collectors/git.ts +60 -2
  21. package/src/daemon/collectors/herdr/collector.ts +128 -0
  22. package/src/daemon/collectors/herdr/index.ts +2 -0
  23. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  24. package/src/daemon/collectors/opencode/collector.ts +4 -2
  25. package/src/daemon/collectors/opencode-db.ts +17 -5
  26. package/src/daemon/collectors/tmux/collector.ts +21 -1
  27. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  28. package/src/daemon/collectors/usage.ts +115 -49
  29. package/src/daemon/index.ts +2 -0
  30. package/src/daemon/store.ts +109 -14
  31. package/src/postinstall.cjs +11 -0
  32. package/src/shared/config.ts +33 -0
  33. package/src/shared/session-exclusions.ts +5 -0
  34. package/src/shared/session-view.ts +67 -6
  35. package/src/shared/terminal-capabilities.ts +130 -0
  36. package/src/shared/types.ts +33 -2
  37. package/src/shared/version.ts +44 -0
  38. package/src/tui/App.tsx +240 -0
  39. package/src/tui/assets/providers/SOURCES.md +21 -0
  40. package/src/tui/assets/providers/claude-terminal.png +0 -0
  41. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  42. package/src/tui/assets/providers/claude.png +0 -0
  43. package/src/tui/assets/providers/copilot.png +0 -0
  44. package/src/tui/assets/providers/copilot.svg +44 -0
  45. package/src/tui/assets/providers/openai-terminal.png +0 -0
  46. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  47. package/src/tui/assets/providers/openai.png +0 -0
  48. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  49. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  50. package/src/tui/assets/providers/opencode.png +0 -0
  51. package/src/tui/assets/providers/shell.png +0 -0
  52. package/src/tui/assets/providers/shell.svg +23 -0
  53. package/src/tui/assets/providers/unknown.png +0 -0
  54. package/src/tui/assets/providers/unknown.svg +23 -0
  55. package/src/tui/client.ts +22 -8
  56. package/src/tui/components/BindingsModal.tsx +82 -0
  57. package/src/tui/components/DashboardHeader.tsx +256 -0
  58. package/src/tui/components/KeyBar.tsx +67 -0
  59. package/src/tui/components/SessionDetailModal.tsx +169 -0
  60. package/src/tui/components/SessionsView.tsx +184 -0
  61. package/src/tui/components/TabBar.tsx +40 -0
  62. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  63. package/src/tui/components/UsageView.tsx +242 -0
  64. package/src/tui/components/tool-icons.ts +24 -0
  65. package/src/tui/index.tsx +106 -0
  66. package/src/tui/keybindings.ts +49 -0
  67. package/src/tui/model.ts +295 -0
  68. package/src/tui/state.ts +77 -0
  69. package/src/tui/theme.ts +110 -0
  70. package/src/usage/README.md +3 -3
  71. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  72. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  73. package/src/usage/clients/claude-cli-probe.ts +458 -132
  74. package/src/usage/clients/codex-app-server.ts +354 -0
  75. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  76. package/src/usage/clients/opencode-go-api.ts +100 -0
  77. package/src/usage/clients/opencode-go-usage.ts +163 -27
  78. package/src/usage/errors.ts +33 -0
  79. package/src/usage/index.ts +2 -1
  80. package/src/usage/local/opencode-go-local.ts +194 -0
  81. package/src/usage/mappers.ts +24 -15
  82. package/src/usage/planner.ts +89 -25
  83. package/src/usage/providers/claude.ts +203 -83
  84. package/src/usage/providers/codex.ts +92 -11
  85. package/src/usage/providers/opencode-go.ts +94 -16
  86. package/src/usage/resolvers/browser-cookies.ts +679 -69
  87. package/src/usage/resolvers/claude-auth.ts +150 -41
  88. package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
  89. package/src/usage/resolvers/claude-web.ts +72 -6
  90. package/src/usage/resolvers/codex-auth.ts +74 -17
  91. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  92. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  93. package/src/usage/types.ts +37 -2
  94. package/src/usage/web-session-cache.ts +37 -0
  95. package/src/neo-blessed.d.ts +0 -4
  96. package/src/tui/app.ts +0 -920
  97. 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 Error {
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
- function pickWindow(data: UsageResponse, keys: string[]): { utilization?: number | null; resets_at?: string | null } | null {
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" && "utilization" in val) {
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 fetch("https://claude.ai/api/organizations", {
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 || orgRes.status === 403) {
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 Error(`Organizations fetch failed: ${orgRes.status}`);
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 Error("No organizations found");
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 fetch(`https://claude.ai/api/organizations/${org.uuid}/usage`, {
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 || usageRes.status === 403) {
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 Error(`Usage fetch failed: ${usageRes.status}`);
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
- if (!data.five_hour || typeof data.five_hour.utilization !== "number") {
145
- throw new Error("Missing five_hour.utilization");
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(data.five_hour.utilization, data.five_hour.resets_at, 300);
149
- const secondary = data.seven_day
150
- ? toRateWindow(data.seven_day.utilization, data.seven_day.resets_at, 10080)
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 = pickWindow(data, ["seven_day_sonnet", "seven_day_opus"]);
154
- const opus = opusWindow ? toRateWindow(opusWindow.utilization, opusWindow.resets_at, 10080) : null;
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 = pickWindow(data, DESIGN_ALTERNATES);
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 = pickWindow(data, ROUTINES_ALTERNATES);
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 fetch(`https://claude.ai/api/organizations/${org.uuid}/overage_spend_limit`, {
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 fetch("https://claude.ai/api/account", {
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
+ }