@scrylog/cli 0.1.3 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrylog/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Terminal dashboard for monitoring tmux sessions running AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,27 +46,7 @@ export function createClaudeWebhookRouter(store: DaemonStore, logger: Logger, gi
46
46
  const storeId = `claude:${sessionId}`;
47
47
  if (eventType === "SessionEnd") {
48
48
  store.markLocalCommand(sessionId);
49
- const existing = store.getSession(storeId);
50
- if (existing) {
51
- store.upsertSession({
52
- ...existing,
53
- state: "idle",
54
- hasAttention: false,
55
- summary: summary ?? existing.summary ?? "Claude session ended",
56
- lastActivity: lastActivity ?? Date.now(),
57
- detectionSource: "claude-hook",
58
- claude: {
59
- sessionId,
60
- transcriptPath: transcriptPath ?? existing.claude?.transcriptPath ?? "",
61
- ...(eventType !== undefined ? { hookEventType: eventType } : {}),
62
- ...(eventTime !== undefined ? { hookEventTime: eventTime } : {}),
63
- ...(hookSeq !== undefined ? { hookSeq } : {}),
64
- ...(endReason !== undefined ? { endReason } : {}),
65
- ...(errorType !== undefined ? { errorType } : {}),
66
- },
67
- });
68
- }
69
-
49
+ store.removeSession(storeId);
70
50
  logger.info("webhook", "claude session ended", { event: "session.ended", tool: "claude", sessionId, reason: endReason });
71
51
  return c.json({ ok: true as const }, 200);
72
52
  }
@@ -1,6 +1,7 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { ScrylogConfig } from "../../../shared/config";
3
3
  import type { DaemonStore } from "../../../daemon/store";
4
+ import type { GitCollector } from "../../../daemon/collectors/git";
4
5
  import type { Logger } from "../../../shared/logger";
5
6
  import type { SessionModel } from "../../../shared/types";
6
7
  import type { OpencodeWebhookPayload } from "../../models/opencode";
@@ -8,7 +9,7 @@ import { mapHookState, extractSessionParentId } from "../../services/opencode-se
8
9
  import { WebhookRouteKey } from "../../docs/webhooks.keys";
9
10
  import { defineRoute } from "../../lib/route";
10
11
 
11
- export function createOpencodeWebhookRouter(store: DaemonStore, config: ScrylogConfig, logger: Logger): OpenAPIHono {
12
+ export function createOpencodeWebhookRouter(store: DaemonStore, config: ScrylogConfig, logger: Logger, gitCollector?: GitCollector): OpenAPIHono {
12
13
  const router = new OpenAPIHono();
13
14
 
14
15
  router.openapi(defineRoute(WebhookRouteKey.WEBHOOK_OPENCODE), async (c) => {
@@ -46,6 +47,7 @@ export function createOpencodeWebhookRouter(store: DaemonStore, config: ScrylogC
46
47
 
47
48
  const title = payload.info?.title ?? payload.title ?? existing?.summary ?? "OpenCode session";
48
49
  const directory = payload.info?.directory ?? payload.directory ?? existing?.currentPath;
50
+ const resolvedProject = directory && gitCollector ? gitCollector.resolve(directory) : undefined;
49
51
  const parentSessionId = extractSessionParentId(event, payload, existing);
50
52
  const timeUpdated = payload.info?.time?.updated ?? Date.now();
51
53
 
@@ -66,6 +68,7 @@ export function createOpencodeWebhookRouter(store: DaemonStore, config: ScrylogC
66
68
  hasAttention,
67
69
  summary: title,
68
70
  currentPath: directory ?? existing.currentPath,
71
+ project: resolvedProject ?? existing.project,
69
72
  parentSessionId,
70
73
  lastActivity: stateChanged ? Date.now() : Math.max(existing.lastActivity, timeUpdated),
71
74
  opencode: {
@@ -88,6 +91,7 @@ export function createOpencodeWebhookRouter(store: DaemonStore, config: ScrylogC
88
91
  detectionSource: "opencode-hook",
89
92
  hasAttention,
90
93
  currentPath: directory,
94
+ project: resolvedProject,
91
95
  parentSessionId,
92
96
  opencode: {
93
97
  sessionId: rawId,
package/src/api/routes.ts CHANGED
@@ -51,7 +51,7 @@ export function createApiApp(
51
51
  app.route("/api", createSystemRouter(store, config, logger));
52
52
  app.route("/api", createSessionsRouter(store, config));
53
53
  app.route("/api", createUsageRouter(store));
54
- app.route("/api/webhook", createOpencodeWebhookRouter(store, config, logger));
54
+ app.route("/api/webhook", createOpencodeWebhookRouter(store, config, logger, gitCollector));
55
55
  app.route("/api/webhook", createClaudeWebhookRouter(store, logger, gitCollector));
56
56
  app.route("/api/webhook", createCodexWebhookRouter(store, logger, gitCollector));
57
57
 
@@ -89,7 +89,7 @@ export function runTokenCommand(target: "mcp" | "api" | "both", action: string,
89
89
  throw new Error(`Unknown token action: ${action}`);
90
90
  }
91
91
 
92
- export async function runLogsCommand(): Promise<void> {
92
+ export async function runLogsCommand(_options: GlobalCliOptions): Promise<void> {
93
93
  const logPath = getDaemonLogPath();
94
94
  process.stdout.write(`Log path: ${logPath}\n`);
95
95
  if (!existsSync(logPath)) {
@@ -247,6 +247,24 @@ export async function runSetupWizard(options: GlobalCliOptions): Promise<void> {
247
247
  }
248
248
  }
249
249
 
250
+ if (isClaudeHooksInstalled()) {
251
+ process.stdout.write("Claude hooks already installed.\n");
252
+ } else {
253
+ const installClaude = await prompt.yesNo("Install Claude hooks now?", false);
254
+ if (installClaude) {
255
+ await runClaudeHooksInstall(options);
256
+ }
257
+ }
258
+
259
+ if (isCodexHooksInstalled()) {
260
+ process.stdout.write("Codex hooks already installed.\n");
261
+ } else {
262
+ const installCodex = await prompt.yesNo("Install Codex hooks now?", false);
263
+ if (installCodex) {
264
+ await runCodexHooksInstall(options);
265
+ }
266
+ }
267
+
250
268
  const pollInterval = await prompt.text("Daemon poll interval (ms)", String(current.daemon.pollIntervalMs));
251
269
  const logLevel = await prompt.choose("Daemon log level", ["debug", "info", "warn", "error"], ["debug", "info", "warn", "error"].indexOf(current.daemon.logLevel));
252
270
  next.daemon.pollIntervalMs = parseOptionalInt(pollInterval, current.daemon.pollIntervalMs);
@@ -326,6 +344,52 @@ function parseOptionalInt(value: string, fallback: number): number {
326
344
 
327
345
  const OPENCODE_PLUGIN_NAME = "@scrylog/opencode-plugin";
328
346
 
347
+ export function isClaudeHooksInstalled(homeDir = homedir()): boolean {
348
+ const settingsPath = join(homeDir, ".claude", "settings.json");
349
+ if (!existsSync(settingsPath)) return false;
350
+ try {
351
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as ClaudeSettings;
352
+ const hooks = settings.hooks ?? {};
353
+ return Object.values(hooks).some((rules) =>
354
+ rules.some((rule) =>
355
+ rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-claude-hook")),
356
+ ),
357
+ );
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
363
+ export function isCodexHooksInstalled(homeDir = homedir()): boolean {
364
+ const codexDir = process.env.CODEX_HOME ? process.env.CODEX_HOME : join(homeDir, ".codex");
365
+ const hooksPath = join(codexDir, "hooks.json");
366
+ if (!existsSync(hooksPath)) return false;
367
+ try {
368
+ const config = JSON.parse(readFileSync(hooksPath, "utf8")) as CodexHooksConfig;
369
+ const hooks = config.hooks ?? {};
370
+ return Object.values(hooks).some((rules) =>
371
+ rules.some((rule) =>
372
+ rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-codex-hook")),
373
+ ),
374
+ );
375
+ } catch {
376
+ return false;
377
+ }
378
+ }
379
+
380
+ export function isOpencodePluginRegistered(homeDir = homedir()): boolean {
381
+ const settingsPath = join(homeDir, ".config", "opencode", "opencode.json");
382
+ if (!existsSync(settingsPath)) return false;
383
+ try {
384
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as OpenCodeSettings;
385
+ return (settings.plugin ?? []).some(
386
+ (plugin) => typeof plugin === "string" && plugin.includes(OPENCODE_PLUGIN_NAME),
387
+ );
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
392
+
329
393
  function backupBeforeWrite(path: string): void {
330
394
  if (!existsSync(path)) return;
331
395
  const bak = `${path}.bak`;
@@ -397,21 +461,21 @@ export async function runClaudeHooksInstall(options: GlobalCliOptions): Promise<
397
461
  mkdirSync(claudeDir, { recursive: true });
398
462
  }
399
463
 
464
+ if (isClaudeHooksInstalled()) {
465
+ process.stdout.write("Claude hooks are already installed.\n");
466
+ return;
467
+ }
468
+
400
469
  // Merge hooks — add scrylog entries without removing existing ones
401
470
  const existingHooks = settings.hooks ?? {};
402
471
  const updatedHooks: Record<string, ClaudeSettingsHookRule[]> = { ...existingHooks };
403
472
 
404
473
  for (const event of CLAUDE_HOOK_EVENTS) {
405
474
  const existing = updatedHooks[event] ?? [];
406
- const alreadyInstalled = existing.some((rule) =>
407
- rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-claude-hook")),
408
- );
409
- if (!alreadyInstalled) {
410
- updatedHooks[event] = [
411
- ...existing,
412
- { matcher: "", hooks: [{ type: "command", command: hookBin }] },
413
- ];
414
- }
475
+ updatedHooks[event] = [
476
+ ...existing,
477
+ { matcher: "", hooks: [{ type: "command", command: hookBin }] },
478
+ ];
415
479
  }
416
480
 
417
481
  settings.hooks = updatedHooks;
@@ -490,22 +554,22 @@ export async function runCodexHooksInstall(options: GlobalCliOptions): Promise<v
490
554
  mkdirSync(codexDir, { recursive: true });
491
555
  }
492
556
 
557
+ if (isCodexHooksInstalled()) {
558
+ process.stdout.write("Codex hooks are already installed.\n");
559
+ return;
560
+ }
561
+
493
562
  // Merge hooks — add scrylog entries without removing existing ones
494
563
  const existingHooks = config.hooks ?? {};
495
564
  const updatedHooks: Record<string, CodexHookRule[]> = { ...existingHooks };
496
565
 
497
566
  for (const event of CODEX_HOOK_EVENTS) {
498
567
  const existing = updatedHooks[event] ?? [];
499
- const alreadyInstalled = existing.some((rule) =>
500
- rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-codex-hook")),
501
- );
502
- if (!alreadyInstalled) {
503
- const entry: CodexHookRule = {
504
- matcher: event === "UserPromptSubmit" || event === "Stop" ? undefined : "",
505
- hooks: [{ type: "command", command: hookBin, ...(event === "Stop" ? { timeout: 30 } : {}) }],
506
- };
507
- updatedHooks[event] = [...existing, entry];
508
- }
568
+ const entry: CodexHookRule = {
569
+ matcher: event === "UserPromptSubmit" || event === "Stop" ? undefined : "",
570
+ hooks: [{ type: "command", command: hookBin, ...(event === "Stop" ? { timeout: 30 } : {}) }],
571
+ };
572
+ updatedHooks[event] = [...existing, entry];
509
573
  }
510
574
 
511
575
  config.hooks = updatedHooks;
@@ -549,9 +613,7 @@ export async function runOpenCodeHooksInstall(options: GlobalCliOptions): Promis
549
613
 
550
614
  // Merge plugin entry without removing existing ones
551
615
  const existingPlugins = settings.plugin ?? [];
552
- const alreadyInstalled = existingPlugins.some(
553
- (plugin) => typeof plugin === "string" && plugin.includes(OPENCODE_PLUGIN_NAME),
554
- );
616
+ const alreadyInstalled = isOpencodePluginRegistered();
555
617
 
556
618
  if (!alreadyInstalled) {
557
619
  settings.plugin = [...existingPlugins, OPENCODE_PLUGIN_NAME];
@@ -145,6 +145,16 @@ export class ClaudeCollector implements Collector {
145
145
  private processTranscript(transcript: import("./discovery").DiscoveredTranscript, _hasRunning: boolean): void {
146
146
  const store = this.store!;
147
147
  const config = this.config!;
148
+
149
+ // Do not re-add sessions that were explicitly ended via SessionEnd hook.
150
+ // The transcript file persists on disk after the session ends; without this
151
+ // guard, the discovery cycle would recreate the session immediately after
152
+ // the webhook removed it.
153
+ if (store.isLocalCommand(transcript.sessionId)) {
154
+ store.removeSession(`claude:${transcript.sessionId}`);
155
+ return;
156
+ }
157
+
148
158
  const existingSession = store.getSession(`claude:${transcript.sessionId}`);
149
159
  const watched = this.watched.get(transcript.path);
150
160
  if (
@@ -258,8 +258,9 @@ export class OpenCodeCollector implements Collector {
258
258
  inst.url,
259
259
  inst.pendingPermissions,
260
260
  inst.pendingQuestions,
261
+ this.gitCollector,
261
262
  );
262
- const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention };
263
+ const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention, project: updated.project ?? existing.project };
263
264
  inst.sessions.set(props.sessionID, merged);
264
265
  store.upsertSession(merged);
265
266
  if (prevState !== merged.state) {
@@ -385,8 +386,9 @@ export class OpenCodeCollector implements Collector {
385
386
  inst.url,
386
387
  inst.pendingPermissions,
387
388
  inst.pendingQuestions,
389
+ this.gitCollector,
388
390
  );
389
- const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention };
391
+ const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention, project: updated.project ?? existing.project };
390
392
  inst.sessions.set(sessionId, merged);
391
393
  store.upsertSession(merged);
392
394
  if (prevState !== merged.state) {
@@ -4,6 +4,22 @@ import { NOOP_LOGGER } from "../shared/logger";
4
4
  import type { CollectorStatus, DaemonState, SessionInfo, SessionModel, StoreEvent } from "../shared/types";
5
5
  import type { RetentionConfig } from "../shared/config";
6
6
 
7
+ /**
8
+ * Returns true when an incoming claude-hook "working" event represents an explicit
9
+ * user response to a pending attention (permission or question), so the attention
10
+ * can be cleared. Background working events (unrelated subagent activity) return false.
11
+ */
12
+ function resolvesAttention(existing: SessionModel, incoming: SessionModel): boolean {
13
+ const evt = incoming.claude?.hookEventType;
14
+ if (evt === "UserPromptSubmit" || evt === "PermissionDenied") return true;
15
+ if (
16
+ (evt === "PreToolUse" || evt === "PostToolUse")
17
+ && existing.claude?.attentionKind === "permission"
18
+ && incoming.claude?.toolName === existing.claude.toolName
19
+ ) return true;
20
+ return false;
21
+ }
22
+
7
23
  /**
8
24
  * In-memory state store for the daemon.
9
25
  * Maintains all session data and emits typed StoreEvent events on changes.
@@ -143,6 +159,18 @@ export class DaemonStore extends EventEmitter {
143
159
  });
144
160
  }
145
161
 
162
+ // For all sessions: preserve a previously-resolved project/currentPath when
163
+ // the incoming model lacks them. Project resolution can transiently fail
164
+ // (Claude slug decode returning null, OpenCode directory not yet populated),
165
+ // and once resolved the association must not oscillate back to undefined.
166
+ // An incoming model that carries a non-empty project still wins.
167
+ if (existing && !session.project && existing.project) {
168
+ session = { ...session, project: existing.project };
169
+ }
170
+ if (existing && !session.currentPath && existing.currentPath) {
171
+ session = { ...session, currentPath: existing.currentPath };
172
+ }
173
+
146
174
  // Preserve stateChangedAt from the stored session when the incoming model
147
175
  // doesn't carry it (e.g. built by processTranscript from file state only).
148
176
  if (existing && !session.stateChangedAt && existing.stateChangedAt) {
@@ -163,25 +191,50 @@ export class DaemonStore extends EventEmitter {
163
191
  session = { ...session, state: existing.state, stateChangedAt: existing.stateChangedAt };
164
192
  } else {
165
193
  this.suppressedIdle.delete(session.id);
166
- const isGracePeriodIdleToWorking = existing.state === "idle"
194
+
195
+ // For hook-managed Claude sessions: pending attention (waiting-input +
196
+ // hasAttention) takes precedence over concurrent background working events
197
+ // until the user explicitly responds. Background subagent activity must not
198
+ // clear the permission/question from the dashboard.
199
+ const pendingAttention = existing.state === "waiting-input"
200
+ && existing.hasAttention === true
167
201
  && session.state === "working"
168
- && existing.lastChildActiveAt
169
- && (now - existing.lastChildActiveAt) < 1500;
202
+ && session.detectionSource === "claude-hook"
203
+ && !resolvesAttention(existing, session);
170
204
 
171
- if (session.state === "working") {
205
+ if (pendingAttention) {
206
+ // Revert to waiting-input, advancing only the claude metadata (hookSeq etc.)
207
+ // so subsequent events are not rejected as stale.
172
208
  session = {
173
209
  ...session,
174
- stableForMs: 0,
175
- stateChangedAt: now,
176
- lastWorkingChangedAt: isGracePeriodIdleToWorking
177
- ? (existing.lastWorkingChangedAt ?? now)
178
- : now,
210
+ state: "waiting-input" as SessionModel["state"],
211
+ hasAttention: true,
212
+ summary: existing.summary,
213
+ activeTool: existing.activeTool,
214
+ stateChangedAt: existing.stateChangedAt,
215
+ stableForMs: existing.stableForMs,
179
216
  };
180
217
  } else {
181
- session = { ...session, stableForMs: 0, stateChangedAt: now };
218
+ const isGracePeriodIdleToWorking = existing.state === "idle"
219
+ && session.state === "working"
220
+ && existing.lastChildActiveAt
221
+ && (now - existing.lastChildActiveAt) < 1500;
222
+
223
+ if (session.state === "working") {
224
+ session = {
225
+ ...session,
226
+ stableForMs: 0,
227
+ stateChangedAt: now,
228
+ lastWorkingChangedAt: isGracePeriodIdleToWorking
229
+ ? (existing.lastWorkingChangedAt ?? now)
230
+ : now,
231
+ };
232
+ } else {
233
+ session = { ...session, stableForMs: 0, stateChangedAt: now };
182
234
 
183
- if (session.state === "waiting-input" || session.state === "error") {
184
- session = { ...session, lastWorkingChangedAt: undefined, lastChildActiveAt: undefined };
235
+ if (session.state === "waiting-input" || session.state === "error") {
236
+ session = { ...session, lastWorkingChangedAt: undefined, lastChildActiveAt: undefined };
237
+ }
185
238
  }
186
239
  }
187
240
  }
@@ -64,7 +64,7 @@ export function resolveDaemonExec(configPath?: string, daemonPath = whichCommand
64
64
  export function renderLaunchAgentPlist(configPath?: string): string {
65
65
  const args = resolveDaemonExec(configPath);
66
66
  const programArguments = args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n");
67
- const path = launchdPath(args);
67
+ const path = resolveServicePath(args);
68
68
  return `<?xml version="1.0" encoding="UTF-8"?>
69
69
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
70
70
  <plist version="1.0">
@@ -91,11 +91,13 @@ ${programArguments}
91
91
 
92
92
  export function renderSystemdUserUnit(configPath?: string): string {
93
93
  const args = resolveDaemonExec(configPath);
94
+ const path = resolveServicePath(args);
94
95
  return `[Unit]
95
96
  Description=scrylog daemon
96
97
  After=default.target
97
98
 
98
99
  [Service]
100
+ Environment="PATH=${path}"
99
101
  ExecStart=${args.map(shellQuote).join(" ")}
100
102
  Restart=on-failure
101
103
  RestartSec=3
@@ -248,7 +250,7 @@ function shellQuote(value: string): string {
248
250
  return `'${value.replace(/'/g, `'"'"'`)}'`;
249
251
  }
250
252
 
251
- function launchdPath(args: string[]): string {
253
+ function resolveServicePath(args: string[]): string {
252
254
  const commandPath = args[0];
253
255
  const candidates = [
254
256
  commandPath && isAbsolute(commandPath) ? dirname(commandPath) : undefined,
package/src/tui/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env bun
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { loadConfig } from "../shared/config";
3
5
  import type { ScrylogConfig } from "../shared/config";
4
6
  import { DaemonClient } from "./client";
@@ -17,7 +19,7 @@ import {
17
19
  runTokenCommand,
18
20
  } from "../cli/commands";
19
21
 
20
- function printHelp() {
22
+ export function printHelp() {
21
23
  process.stdout.write(`scrylog
22
24
 
23
25
  Usage:
@@ -30,11 +32,18 @@ Usage:
30
32
  scrylog claude hooks install [--config <path>]
31
33
  scrylog codex hooks install [--config <path>]
32
34
  scrylog opencode hooks install [--config <path>]
33
- scrylog logs
35
+ scrylog logs [--config <path>]
34
36
 
35
37
  Options:
36
38
  --config <path> Load config from an explicit path
37
39
  -h, --help Show this help text
40
+ -V, --version Show the installed scrylog version
41
+
42
+ See also:
43
+ scrylogd Background daemon (collects sessions and serves API/MCP)
44
+ scrylog-mcp stdio MCP server that proxies daemon data to AI agents
45
+ scrylog-claude-hook Claude Code lifecycle hook binary
46
+ scrylog-codex-hook Codex CLI lifecycle hook binary
38
47
 
39
48
  Notes:
40
49
  Running scrylog with no subcommand launches the TUI dashboard.
@@ -47,6 +56,11 @@ Notes:
47
56
  `);
48
57
  }
49
58
 
59
+ export function printVersion() {
60
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8")) as { version?: string };
61
+ process.stdout.write(`scrylog ${pkg.version ?? "unknown"}\n`);
62
+ }
63
+
50
64
  function extractGlobalOptions(argv: string[]) {
51
65
  const args = [...argv];
52
66
  let configPath: string | undefined;
@@ -106,8 +120,24 @@ export function printDaemonNotRunning(config: ScrylogConfig, platform: ServiceSt
106
120
  }
107
121
  }
108
122
 
123
+ export const TOKEN_USAGE = "Usage: scrylog token <mcp|api|both> <generate|rotate|clear>";
124
+
125
+ export function parseTokenArgs(args: string[]): { target: "mcp" | "api" | "both"; action: string } {
126
+ const [target, action] = args;
127
+ if (!target) throw new Error(`Missing token target (mcp, api, or both). ${TOKEN_USAGE}`);
128
+ if (!action) throw new Error(`Missing token action (generate, rotate, or clear). ${TOKEN_USAGE}`);
129
+ if (target !== "mcp" && target !== "api" && target !== "both") {
130
+ throw new Error(`Invalid token target: ${target}. Must be mcp, api, or both. ${TOKEN_USAGE}`);
131
+ }
132
+ return { target, action };
133
+ }
134
+
109
135
  async function main(): Promise<void> {
110
136
  const { args, options } = extractGlobalOptions(process.argv.slice(2));
137
+ if (args.includes("--version") || args.includes("-V")) {
138
+ printVersion();
139
+ process.exit(0);
140
+ }
111
141
  if (args.includes("--help") || args.includes("-h")) {
112
142
  printHelp();
113
143
  process.exit(0);
@@ -142,19 +172,13 @@ async function main(): Promise<void> {
142
172
  }
143
173
 
144
174
  if (command === "token") {
145
- if (!subcommand) throw new Error("Missing token target (mcp, api, or both)");
146
- const action = args[2];
147
- if (!action) throw new Error("Missing token action (generate, rotate, or clear)");
148
- const target = subcommand;
149
- if (target !== "mcp" && target !== "api" && target !== "both") {
150
- throw new Error(`Invalid token target: ${target}. Must be mcp, api, or both.`);
151
- }
152
- runTokenCommand(target as "mcp" | "api" | "both", action, options);
175
+ const { target, action } = parseTokenArgs([subcommand, args[2]]);
176
+ runTokenCommand(target, action, options);
153
177
  return;
154
178
  }
155
179
 
156
180
  if (command === "logs") {
157
- await runLogsCommand();
181
+ await runLogsCommand(options);
158
182
  return;
159
183
  }
160
184
 
@@ -8,11 +8,12 @@ export { ClaudeUsageProvider } from "./providers/claude";
8
8
  export { CodexUsageProvider } from "./providers/codex";
9
9
  export { OpenCodeGoUsageProvider } from "./providers/opencode-go";
10
10
  export { ClaudeAuthResolver } from "./resolvers/claude-auth";
11
+ export { ClaudeDesktopAuthResolver } from "./resolvers/claude-desktop-auth";
11
12
  export { ClaudeWebResolver } from "./resolvers/claude-web";
12
13
  export { ClaudeCliResolver } from "./resolvers/claude-cli";
13
14
  export { CodexAuthResolver } from "./resolvers/codex-auth";
14
15
  export { OpenCodeGoWebResolver } from "./resolvers/opencode-go-web";
15
- export { extractCookies, buildCookieHeader } from "./resolvers/browser-cookies";
16
+ export { extractCookies, buildCookieHeader, decryptSafeStorageBlob, getChromiumSafeStorageKey } from "./resolvers/browser-cookies";
16
17
  export { AnthropicOAuthUsageClient } from "./clients/anthropic-oauth-usage";
17
18
  export { AnthropicWebUsageClient } from "./clients/anthropic-web-usage";
18
19
  export { CodexOAuthUsageClient } from "./clients/codex-oauth-usage";
@@ -1,5 +1,5 @@
1
- import { execSync } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
1
+ import * as child_process from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { createDecipheriv, pbkdf2Sync } from "node:crypto";
5
5
  import { join } from "node:path";
@@ -11,32 +11,161 @@ export interface BrowserCookie {
11
11
  value: string;
12
12
  }
13
13
 
14
- const CHROME_COOKIE_PATHS = ((): string[] => {
14
+ // ---------------------------------------------------------------------------
15
+ // Browser catalog types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface ChromiumProfileEntry {
19
+ /** Unique browser ID, e.g. "chrome", "brave", "claude-desktop" */
20
+ id: string;
21
+ /** Relative path under ~/Library/Application Support/ on macOS */
22
+ macOSPath: string;
23
+ /** Relative path under ~/.config/ on Linux */
24
+ linuxPath: string;
25
+ /** Relative path under %APPDATA%/ on Windows */
26
+ windowsPath: string;
27
+ /** macOS Keychain service label, e.g. "Chrome Safe Storage" */
28
+ keychainService?: string;
29
+ /** macOS Keychain account label, e.g. "Chrome" */
30
+ keychainAccount?: string;
31
+ }
32
+
33
+ interface GeckoProfileEntry {
34
+ /** Unique browser ID, e.g. "firefox", "zen" */
35
+ id: string;
36
+ /**
37
+ * Profiles parent folder name.
38
+ * macOS: ~/Library/Application Support/<profilesFolder>/Profiles
39
+ * Linux: ~/.mozilla/<profilesFolder>/Profiles
40
+ */
41
+ profilesFolder: string;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Browser catalogs
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export const CHROMIUM_PROFILES: ChromiumProfileEntry[] = [
49
+ {
50
+ id: "chrome",
51
+ macOSPath: "Google/Chrome",
52
+ linuxPath: "google-chrome",
53
+ windowsPath: "Google/Chrome/User Data",
54
+ keychainService: "Chrome Safe Storage",
55
+ keychainAccount: "Chrome",
56
+ },
57
+ {
58
+ id: "edge",
59
+ macOSPath: "Microsoft Edge",
60
+ linuxPath: "microsoft-edge",
61
+ windowsPath: "Microsoft/Edge/User Data",
62
+ keychainService: "Microsoft Edge Safe Storage",
63
+ keychainAccount: "Microsoft Edge",
64
+ },
65
+ {
66
+ id: "brave",
67
+ macOSPath: "BraveSoftware/Brave-Browser",
68
+ linuxPath: "BraveSoftware/Brave-Browser",
69
+ windowsPath: "BraveSoftware/Brave-Browser/User Data",
70
+ keychainService: "Brave Safe Storage",
71
+ keychainAccount: "Brave",
72
+ },
73
+ {
74
+ id: "arc",
75
+ macOSPath: "Arc/User Data",
76
+ linuxPath: "Arc",
77
+ windowsPath: "Arc/User Data",
78
+ keychainService: "Arc Safe Storage",
79
+ keychainAccount: "Arc",
80
+ },
81
+ {
82
+ id: "vivaldi",
83
+ macOSPath: "Vivaldi",
84
+ linuxPath: "vivaldi",
85
+ windowsPath: "Vivaldi/User Data",
86
+ keychainService: "Vivaldi Safe Storage",
87
+ keychainAccount: "Vivaldi",
88
+ },
89
+ {
90
+ id: "chromium",
91
+ macOSPath: "Chromium",
92
+ linuxPath: "chromium",
93
+ windowsPath: "Chromium/User Data",
94
+ keychainService: "Chromium Safe Storage",
95
+ keychainAccount: "Chromium",
96
+ },
97
+ {
98
+ id: "yandex",
99
+ macOSPath: "Yandex/YandexBrowser",
100
+ linuxPath: "yandex-browser",
101
+ windowsPath: "Yandex/YandexBrowser/User Data",
102
+ keychainService: "Yandex Safe Storage",
103
+ keychainAccount: "Yandex",
104
+ },
105
+ {
106
+ // Claude Desktop app (Electron/Chromium) — macOS and Windows only
107
+ id: "claude-desktop",
108
+ macOSPath: "Claude",
109
+ linuxPath: "", // not available on Linux
110
+ windowsPath: "Anthropic/Claude",
111
+ keychainService: "Claude Safe Storage",
112
+ keychainAccount: "Claude",
113
+ },
114
+ ];
115
+
116
+ export const GECKO_PROFILES: GeckoProfileEntry[] = [
117
+ { id: "firefox", profilesFolder: "Firefox" },
118
+ { id: "zen", profilesFolder: "zen" },
119
+ ];
120
+
121
+ const SAFARI_COOKIE_PATH = join(homedir(), "Library", "Cookies", "Cookies.binarycookies");
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Platform root resolution
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function chromiumRootPath(entry: ChromiumProfileEntry): string {
15
128
  const home = homedir();
16
129
  if (IS_MAC) {
17
- return [
18
- join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Cookies"),
19
- join(home, "Library", "Application Support", "Google", "Chrome", "Profile 1", "Cookies"),
20
- ];
130
+ if (!entry.macOSPath) return "";
131
+ return join(home, "Library", "Application Support", entry.macOSPath);
21
132
  }
22
133
  if (IS_WINDOWS) {
23
- return [
24
- join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cookies"),
25
- ];
134
+ const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
135
+ if (!entry.windowsPath) return "";
136
+ return join(appData, entry.windowsPath);
26
137
  }
27
- // Linux / WSL
28
- return [
29
- join(home, ".config", "google-chrome", "Default", "Cookies"),
30
- join(home, ".config", "chromium", "Default", "Cookies"),
31
- ];
32
- })();
138
+ // Linux
139
+ if (!entry.linuxPath) return "";
140
+ return join(home, ".config", entry.linuxPath);
141
+ }
33
142
 
34
- const SAFARI_COOKIE_PATH = join(homedir(), "Library", "Cookies", "Cookies.binarycookies");
143
+ function geckoProfilesRoot(entry: GeckoProfileEntry): string {
144
+ const home = homedir();
145
+ if (IS_MAC) {
146
+ return join(home, "Library", "Application Support", entry.profilesFolder, "Profiles");
147
+ }
148
+ if (IS_WINDOWS) {
149
+ // Firefox on Windows is not in scope; skip
150
+ return "";
151
+ }
152
+ // Linux
153
+ return join(home, ".mozilla", entry.profilesFolder, "Profiles");
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Chromium helpers
158
+ // ---------------------------------------------------------------------------
35
159
 
36
- function getChromeSafeStorageKey(): Buffer | null {
160
+ /**
161
+ * Derives the Chromium AES-128-CBC key for a given Keychain service label.
162
+ * macOS: reads from Keychain via `security` then applies PBKDF2.
163
+ * Linux/Windows: uses the fixed "peanuts" PBKDF2 key (no Keychain).
164
+ */
165
+ export function getChromiumSafeStorageKey(service: string, _account: string): Buffer | null {
37
166
  try {
38
167
  if (IS_MAC) {
39
- const password = execSync('security find-generic-password -s "Chrome Safe Storage" -w', {
168
+ const password = child_process.execSync(`security find-generic-password -s "${service}" -w`, {
40
169
  encoding: "utf8",
41
170
  stdio: ["pipe", "pipe", "pipe"],
42
171
  }).trim();
@@ -45,79 +174,190 @@ function getChromeSafeStorageKey(): Buffer | null {
45
174
  }
46
175
 
47
176
  if (IS_WINDOWS) {
48
- return null;
177
+ // DPAPI not implemented; fall back to peanuts key (partial compatibility)
178
+ return pbkdf2Sync("peanuts", "saltysalt", 1003, 16, "sha1");
49
179
  }
180
+
181
+ // Linux
50
182
  return pbkdf2Sync("peanuts", "saltysalt", 1003, 16, "sha1");
51
183
  } catch {
52
184
  return null;
53
185
  }
54
186
  }
55
187
 
56
- function decryptChromeCookie(encrypted: Buffer, key: Buffer, dbVersion = 0): string | null {
57
- if (encrypted.length < 3) return null;
58
- // v10 or v11 prefix
59
- const prefix = encrypted.slice(0, 3).toString("ascii");
188
+ /**
189
+ * Returns all profile subdirectory paths under rootPath that match the
190
+ * Chromium naming conventions: Default, Profile *, user-*.
191
+ */
192
+ export function discoverChromiumProfiles(rootPath: string): string[] {
193
+ if (!existsSync(rootPath)) return [];
194
+ let entries: string[];
195
+ try {
196
+ entries = readdirSync(rootPath, { withFileTypes: true })
197
+ .filter((d) => d.isDirectory())
198
+ .map((d) => d.name);
199
+ } catch {
200
+ return [];
201
+ }
202
+ return entries
203
+ .filter((name) => name === "Default" || /^Profile \d+$/.test(name) || /^user-/.test(name))
204
+ .map((name) => join(rootPath, name));
205
+ }
206
+
207
+ /**
208
+ * Returns the two candidate cookie DB paths for a Chromium profile directory.
209
+ * The caller is responsible for checking existence before opening.
210
+ */
211
+ export function chromiumCookieDbPaths(profilePath: string): string[] {
212
+ return [join(profilePath, "Cookies"), join(profilePath, "Network", "Cookies")];
213
+ }
214
+
215
+ /**
216
+ * Internal: decrypt AES-128-CBC safeStorage payload, returning raw bytes after
217
+ * PKCS#7 un-padding. Returns `null` on decryption failure. Callers that need
218
+ * the Chrome v24+ 32-byte domain-hash strip must apply it themselves.
219
+ */
220
+ function decryptSafeStorageRaw(buf: Buffer, key: Buffer): Buffer | null {
221
+ if (buf.length < 3) return null;
222
+ const prefix = buf.slice(0, 3).toString("ascii");
60
223
  if (prefix !== "v10" && prefix !== "v11") {
61
- // Possibly plain text (older Chrome versions)
62
- return encrypted.toString("utf8");
224
+ return buf; // plain text (older versions)
63
225
  }
64
226
  const iv = Buffer.alloc(16, 32); // space character (0x20) repeated 16 times
65
227
  try {
66
228
  const decipher = createDecipheriv("aes-128-cbc", key, iv);
67
- let decrypted = Buffer.concat([decipher.update(encrypted.slice(3)), decipher.final()]);
229
+ let decrypted = Buffer.concat([decipher.update(buf.slice(3)), decipher.final()]);
68
230
  // Remove PKCS#7 padding
69
231
  const padLen = decrypted[decrypted.length - 1];
70
232
  if (padLen > 0 && padLen <= 16) {
71
233
  decrypted = decrypted.slice(0, decrypted.length - padLen);
72
234
  }
73
- // Chrome v24+ prepends a SHA256 hash of the domain (32 bytes) to encrypted values
74
- if (dbVersion >= 24 && decrypted.length >= 32) {
75
- decrypted = decrypted.slice(32);
76
- }
77
- return decrypted.toString("utf8");
235
+ return decrypted;
78
236
  } catch {
79
237
  return null;
80
238
  }
81
239
  }
82
240
 
83
- function readChromeCookies(domain: string): BrowserCookie[] {
241
+ /**
242
+ * Decrypt a Chromium/Electron safeStorage blob (AES-128-CBC, v10/v11 prefix)
243
+ * and return the result as a UTF-8 string.
244
+ *
245
+ * Used for Electron `safeStorage`-encrypted config fields (e.g. `oauth:tokenCache`
246
+ * in Claude Desktop's `config.json`). Does **not** strip the Chrome v24+ 32-byte
247
+ * domain-hash prefix — use `decryptChromiumCookie` for cookie DB values.
248
+ */
249
+ export function decryptSafeStorageBlob(buf: Buffer, key: Buffer): string | null {
250
+ const raw = decryptSafeStorageRaw(buf, key);
251
+ return raw !== null ? raw.toString("utf8") : null;
252
+ }
253
+
254
+ function decryptChromiumCookie(encrypted: Buffer, key: Buffer, dbVersion = 0): string | null {
255
+ const raw = decryptSafeStorageRaw(encrypted, key);
256
+ if (raw === null) return null;
257
+ // Chrome v24+ prepends a SHA256 hash of the domain (32 bytes) to encrypted cookie values
258
+ if (encrypted.length >= 3) {
259
+ const prefix = encrypted.slice(0, 3).toString("ascii");
260
+ if ((prefix === "v10" || prefix === "v11") && dbVersion >= 24 && raw.length >= 32) {
261
+ return raw.slice(32).toString("utf8");
262
+ }
263
+ }
264
+ return raw.toString("utf8");
265
+ }
266
+
267
+ export function readChromiumCookies(entry: ChromiumProfileEntry, domain: string): BrowserCookie[] {
84
268
  const cookies: BrowserCookie[] = [];
85
- const key = getChromeSafeStorageKey();
86
- if (!key) return cookies;
269
+ const rootPath = chromiumRootPath(entry);
270
+ if (!rootPath || !existsSync(rootPath)) return cookies;
87
271
 
88
- for (const dbPath of CHROME_COOKIE_PATHS) {
89
- if (!existsSync(dbPath)) continue;
90
- let db: Database | undefined;
91
- try {
92
- db = new Database(dbPath, { readonly: true });
272
+ // Resolve decryption key only if we found a root (avoids Keychain prompts for absent browsers)
273
+ const key = getChromiumSafeStorageKey(
274
+ entry.keychainService ?? "",
275
+ entry.keychainAccount ?? ""
276
+ );
277
+ if (!key) return cookies;
93
278
 
94
- // Chrome v24+ prepends a SHA256 hash (32 bytes) to encrypted cookie values
95
- let dbVersion = 0;
279
+ const profiles = discoverChromiumProfiles(rootPath);
280
+ for (const profilePath of profiles) {
281
+ for (const dbPath of chromiumCookieDbPaths(profilePath)) {
282
+ if (!existsSync(dbPath)) continue;
283
+ let db: Database | undefined;
96
284
  try {
97
- const versionStmt = db.query("SELECT value FROM meta WHERE key = 'version'");
98
- const versionRow = versionStmt.get() as { value: number } | null;
99
- if (versionRow) dbVersion = Number(versionRow.value);
285
+ db = new Database(dbPath, { readonly: true });
286
+
287
+ // Chrome v24+ prepends a SHA256 hash (32 bytes) to encrypted cookie values
288
+ let dbVersion = 0;
289
+ try {
290
+ const versionStmt = db.query("SELECT value FROM meta WHERE key = 'version'");
291
+ const versionRow = versionStmt.get() as { value: number } | null;
292
+ if (versionRow) dbVersion = Number(versionRow.value);
293
+ } catch {
294
+ // meta table may not exist in older DBs
295
+ }
296
+
297
+ const stmt = db.query(
298
+ "SELECT name, encrypted_value, host_key FROM cookies WHERE host_key LIKE ? AND expires_utc > ?"
299
+ );
300
+ const now = (Date.now() + 11644473600000) * 1000; // Chrome epoch offset
301
+ const rows = stmt.all(`%${domain}%`, now) as Array<{
302
+ name: string;
303
+ encrypted_value: Uint8Array;
304
+ host_key: string;
305
+ }>;
306
+ for (const row of rows) {
307
+ const value = decryptChromiumCookie(Buffer.from(row.encrypted_value), key, dbVersion);
308
+ if (value !== null) {
309
+ cookies.push({ name: row.name, value });
310
+ }
311
+ }
100
312
  } catch {
101
- // meta table may not exist in older DBs
313
+ // Ignore permission errors, locked DB (SQLITE_BUSY), etc.
314
+ } finally {
315
+ db?.close();
102
316
  }
317
+ }
318
+ }
319
+
320
+ return cookies;
321
+ }
103
322
 
104
- const stmt = db.query(
105
- "SELECT name, encrypted_value, host_key FROM cookies WHERE host_key LIKE ? AND expires_utc > ?"
106
- );
107
- const now = (Date.now() + 11644473600000) * 1000; // Chrome epoch offset
108
- const rows = stmt.all(`%${domain}%`, now) as Array<{
109
- name: string;
110
- encrypted_value: Uint8Array;
111
- host_key: string;
112
- }>;
323
+ // ---------------------------------------------------------------------------
324
+ // Gecko (Firefox/Zen) support
325
+ // ---------------------------------------------------------------------------
326
+
327
+ export function readGeckoCookies(entry: GeckoProfileEntry, domain: string): BrowserCookie[] {
328
+ const cookies: BrowserCookie[] = [];
329
+ const profilesRoot = geckoProfilesRoot(entry);
330
+ if (!profilesRoot || !existsSync(profilesRoot)) return cookies;
331
+
332
+ let profileNames: string[];
333
+ try {
334
+ profileNames = readdirSync(profilesRoot, { withFileTypes: true })
335
+ .filter((d) => d.isDirectory() && d.name.includes(".default"))
336
+ .map((d) => d.name)
337
+ // Prefer .default-release over .default
338
+ .sort((a, b) => {
339
+ const aScore = a.endsWith(".default-release") ? 1 : 0;
340
+ const bScore = b.endsWith(".default-release") ? 1 : 0;
341
+ return bScore - aScore;
342
+ });
343
+ } catch {
344
+ return cookies;
345
+ }
346
+
347
+ for (const profileName of profileNames) {
348
+ const dbPath = join(profilesRoot, profileName, "cookies.sqlite");
349
+ if (!existsSync(dbPath)) continue;
350
+ let db: Database | undefined;
351
+ try {
352
+ db = new Database(dbPath, { readonly: true });
353
+ const stmt = db.query("SELECT name, value FROM moz_cookies WHERE host LIKE ?");
354
+ const rows = stmt.all(`%${domain}%`) as Array<{ name: string; value: string }>;
113
355
  for (const row of rows) {
114
- const value = decryptChromeCookie(Buffer.from(row.encrypted_value), key, dbVersion);
115
- if (value !== null) {
116
- cookies.push({ name: row.name, value });
117
- }
356
+ cookies.push({ name: row.name, value: row.value });
118
357
  }
358
+ if (cookies.length > 0) break; // first-profile-wins within a browser
119
359
  } catch {
120
- // Ignore permission errors or locked DB
360
+ // Ignore locked or corrupt DB
121
361
  } finally {
122
362
  db?.close();
123
363
  }
@@ -126,6 +366,10 @@ function readChromeCookies(domain: string): BrowserCookie[] {
126
366
  return cookies;
127
367
  }
128
368
 
369
+ // ---------------------------------------------------------------------------
370
+ // Safari (macOS-only, unchanged)
371
+ // ---------------------------------------------------------------------------
372
+
129
373
  function readSafariCookies(domain: string): BrowserCookie[] {
130
374
  const cookies: BrowserCookie[] = [];
131
375
  if (process.platform !== "darwin") return cookies;
@@ -134,7 +378,7 @@ function readSafariCookies(domain: string): BrowserCookie[] {
134
378
  try {
135
379
  const data = readFileSync(SAFARI_COOKIE_PATH);
136
380
  // Simple binarycookies parser
137
- // Header: 4 bytes magic "cook", 4 bytes page size, then pages
381
+ // Header: 4 bytes magic "cook", 4 bytes page count, then pages
138
382
  if (data.length < 8 || data.toString("ascii", 0, 4) !== "cook") return cookies;
139
383
 
140
384
  const numPages = data.readUInt32BE(4);
@@ -185,11 +429,39 @@ function readSafariCookies(domain: string): BrowserCookie[] {
185
429
  return cookies;
186
430
  }
187
431
 
432
+ // ---------------------------------------------------------------------------
433
+ // Public API
434
+ // ---------------------------------------------------------------------------
435
+
436
+ /**
437
+ * Extract cookies for the given domain from all available browser sources.
438
+ * Sources are tried in order that minimises Keychain prompts:
439
+ * macOS: Gecko (Firefox/Zen) → Safari → Chromium catalog
440
+ * Linux: Gecko → Chromium catalog
441
+ * Windows: Gecko (limited) → Chromium catalog
442
+ *
443
+ * Returns the first source that yields ≥1 matching cookie; subsequent sources
444
+ * are skipped (first-source-wins).
445
+ */
188
446
  export function extractCookies(domain: string): BrowserCookie[] {
189
- const chrome = readChromeCookies(domain);
190
- if (chrome.length > 0) return chrome;
191
- // Safari is only available on macOS.
192
- if (IS_MAC) return readSafariCookies(domain);
447
+ // 1. Gecko sources first — no Keychain prompt, cheap read
448
+ for (const entry of GECKO_PROFILES) {
449
+ const cookies = readGeckoCookies(entry, domain);
450
+ if (cookies.length > 0) return cookies;
451
+ }
452
+
453
+ // 2. Safari (macOS only) — no Keychain prompt
454
+ if (IS_MAC) {
455
+ const safari = readSafariCookies(domain);
456
+ if (safari.length > 0) return safari;
457
+ }
458
+
459
+ // 3. Chromium catalog — may trigger Keychain prompts on macOS
460
+ for (const entry of CHROMIUM_PROFILES) {
461
+ const cookies = readChromiumCookies(entry, domain);
462
+ if (cookies.length > 0) return cookies;
463
+ }
464
+
193
465
  return [];
194
466
  }
195
467
 
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import type { TokenResolver } from "../resolver";
6
6
  import type { ClaudeOAuthCredentials } from "../types";
7
7
  import { IS_MAC } from "../../shared/platform";
8
+ import { ClaudeDesktopAuthResolver } from "./claude-desktop-auth";
8
9
 
9
10
  export class ClaudeAuthResolver implements TokenResolver<ClaudeOAuthCredentials> {
10
11
  private readonly debug = (message: string, meta?: Record<string, unknown>) => {
@@ -26,11 +27,19 @@ export class ClaudeAuthResolver implements TokenResolver<ClaudeOAuthCredentials>
26
27
  this.debug("Keychain not available on this platform", { platform: process.platform });
27
28
  }
28
29
 
29
- // 2. Linux/WSL/Windows: Claude Code stores credentials in ~/.claude/.credentials.json
30
+ // 2. Claude Desktop app local token cache (macOS / Windows only).
31
+ // Reads the Electron safeStorage-encrypted `oauth:tokenCache` from
32
+ // ~/Library/Application Support/Claude/config.json (macOS) or
33
+ // %APPDATA%/Anthropic/Claude/config.json (Windows). This covers the
34
+ // common case where the user has Claude Desktop but not Claude Code CLI.
35
+ const desktopCreds = await new ClaudeDesktopAuthResolver().resolve();
36
+ if (desktopCreds) return desktopCreds;
37
+
38
+ // 3. Linux/WSL/Windows: Claude Code stores credentials in ~/.claude/.credentials.json
30
39
  const fileCreds = this.tryCredentialsFile();
31
40
  if (fileCreds) return fileCreds;
32
41
 
33
- // 3. Universal env-var fallback
42
+ // 4. Universal env-var fallback
34
43
  const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
35
44
  if (envToken) {
36
45
  return { accessToken: envToken };
@@ -0,0 +1,160 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { TokenResolver } from "../resolver";
5
+ import type { ClaudeOAuthCredentials } from "../types";
6
+ import { IS_MAC, IS_WINDOWS } from "../../shared/platform";
7
+ import { getChromiumSafeStorageKey, decryptSafeStorageBlob } from "./browser-cookies";
8
+
9
+ /**
10
+ * Shape of a single entry in the `oauth:tokenCache` field of Claude Desktop's
11
+ * `config.json`. The key in the outer Record is a colon-separated composite:
12
+ * `<accountId>:<orgId>:<audience>:<space-separated scopes>`
13
+ */
14
+ interface TokenCacheEntry {
15
+ token: string;
16
+ refreshToken?: string;
17
+ /** Milliseconds since Unix epoch */
18
+ expiresAt?: number;
19
+ subscriptionType?: string;
20
+ rateLimitTier?: string;
21
+ }
22
+
23
+ /**
24
+ * Reads the Claude Desktop app's local OAuth token cache and surfaces it as a
25
+ * `ClaudeOAuthCredentials` so the OAuth usage route works without Claude Code CLI.
26
+ *
27
+ * Data source:
28
+ * macOS: ~/Library/Application Support/Claude/config.json → oauth:tokenCache
29
+ * Windows: %APPDATA%/Anthropic/Claude/config.json → oauth:tokenCache
30
+ * Linux: Claude Desktop is unavailable; resolver always returns null.
31
+ *
32
+ * The `oauth:tokenCache` value is an Electron safeStorage blob (base64, v10/v11
33
+ * AES-128-CBC) decrypted with the Keychain key from "Claude Safe Storage".
34
+ */
35
+ export class ClaudeDesktopAuthResolver implements TokenResolver<ClaudeOAuthCredentials> {
36
+ private readonly debug = (message: string, meta?: Record<string, unknown>) => {
37
+ if (process.env.SCRYLOG_DEBUG_AUTH) {
38
+ process.stderr.write(
39
+ `[scrylog:claude-desktop-auth] ${message}${meta ? ` ${JSON.stringify(meta)}` : ""}\n`
40
+ );
41
+ }
42
+ };
43
+
44
+ async resolve(): Promise<ClaudeOAuthCredentials | null> {
45
+ const configPath = this.getConfigPath();
46
+ if (!configPath) {
47
+ this.debug("Claude Desktop not supported on this platform", { platform: process.platform });
48
+ return null;
49
+ }
50
+ if (!existsSync(configPath)) {
51
+ this.debug("Claude Desktop config.json not found", { path: configPath });
52
+ return null;
53
+ }
54
+
55
+ try {
56
+ const raw = readFileSync(configPath, "utf8");
57
+ const cfg = JSON.parse(raw) as Record<string, unknown>;
58
+ const blobB64 = cfg["oauth:tokenCache"];
59
+ if (typeof blobB64 !== "string" || !blobB64) {
60
+ this.debug("oauth:tokenCache missing or not a string in config.json");
61
+ return null;
62
+ }
63
+
64
+ const buf = Buffer.from(blobB64, "base64");
65
+ const key = getChromiumSafeStorageKey("Claude Safe Storage", "Claude");
66
+ if (!key) {
67
+ this.debug("Failed to retrieve Claude Safe Storage key from Keychain");
68
+ return null;
69
+ }
70
+
71
+ const plaintext = decryptSafeStorageBlob(buf, key);
72
+ if (!plaintext) {
73
+ this.debug("Failed to decrypt oauth:tokenCache blob");
74
+ return null;
75
+ }
76
+
77
+ const cache = JSON.parse(plaintext) as Record<string, TokenCacheEntry>;
78
+ const creds = this.selectBestEntry(cache);
79
+ if (creds) {
80
+ this.debug("Resolved Claude Desktop OAuth credentials");
81
+ } else {
82
+ this.debug("No usable entry found in oauth:tokenCache");
83
+ }
84
+ return creds;
85
+ } catch (err) {
86
+ this.debug("Error reading Claude Desktop config", {
87
+ error: err instanceof Error ? err.message : String(err),
88
+ });
89
+ return null;
90
+ }
91
+ }
92
+
93
+ async isAvailable(): Promise<boolean> {
94
+ return (await this.resolve()) !== null;
95
+ }
96
+
97
+ private getConfigPath(): string | null {
98
+ const home = homedir();
99
+ if (IS_MAC) {
100
+ return join(home, "Library", "Application Support", "Claude", "config.json");
101
+ }
102
+ if (IS_WINDOWS) {
103
+ const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
104
+ return join(appData, "Anthropic", "Claude", "config.json");
105
+ }
106
+ // Linux: Claude Desktop is not available
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Pick the best (longest-lived, has user:profile scope) non-expired token entry.
112
+ *
113
+ * The cache key format is:
114
+ * `<accountId>:<orgId>:<audience>:<space-separated scopes>`
115
+ * e.g.
116
+ * `uuid1:uuid2:https://api.anthropic.com:user:inference user:file_upload user:profile ...`
117
+ *
118
+ * AnthropicOAuthUsageClient requires the `user:profile` scope, so entries that
119
+ * lack it are skipped.
120
+ */
121
+ private selectBestEntry(
122
+ cache: Record<string, TokenCacheEntry>
123
+ ): ClaudeOAuthCredentials | null {
124
+ const now = Date.now();
125
+ let bestCreds: ClaudeOAuthCredentials | null = null;
126
+ let bestExpiresAt = -Infinity;
127
+
128
+ for (const [scopeKey, entry] of Object.entries(cache)) {
129
+ if (!entry.token) continue;
130
+ // Discard expired entries
131
+ if (entry.expiresAt !== undefined && entry.expiresAt <= now) continue;
132
+ // Require user:profile scope for the OAuth usage endpoint
133
+ if (!scopeKey.includes("user:profile")) continue;
134
+
135
+ const expiresAt = entry.expiresAt ?? Infinity;
136
+ if (expiresAt > bestExpiresAt) {
137
+ bestExpiresAt = expiresAt;
138
+
139
+ // Parse space-separated scopes from the composite key.
140
+ // Key format after the audience URL: "...:<audience>:<scopes>"
141
+ const audienceMatch = scopeKey.match(/:[^:]+:\/\/[^:]+:(.+)$/);
142
+ const scopes = audienceMatch ? audienceMatch[1].split(" ") : undefined;
143
+
144
+ bestCreds = {
145
+ accessToken: entry.token,
146
+ refreshToken: entry.refreshToken,
147
+ expiresAt:
148
+ entry.expiresAt !== undefined
149
+ ? new Date(entry.expiresAt).toISOString()
150
+ : undefined,
151
+ scopes,
152
+ subscriptionType: entry.subscriptionType,
153
+ rateLimitTier: entry.rateLimitTier,
154
+ };
155
+ }
156
+ }
157
+
158
+ return bestCreds;
159
+ }
160
+ }