@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 +1 -1
- package/src/api/routes/webhooks/claude.ts +1 -21
- package/src/api/routes/webhooks/opencode.ts +5 -1
- package/src/api/routes.ts +1 -1
- package/src/cli/commands.ts +85 -23
- package/src/daemon/collectors/claude/collector.ts +10 -0
- package/src/daemon/collectors/opencode/collector.ts +4 -2
- package/src/daemon/store.ts +65 -12
- package/src/service/manager.ts +4 -2
- package/src/tui/index.ts +35 -11
- package/src/usage/index.ts +2 -1
- package/src/usage/resolvers/browser-cookies.ts +337 -65
- package/src/usage/resolvers/claude-auth.ts +11 -2
- package/src/usage/resolvers/claude-desktop-auth.ts +160 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/cli/commands.ts
CHANGED
|
@@ -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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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 =
|
|
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) {
|
package/src/daemon/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
&&
|
|
169
|
-
&& (
|
|
202
|
+
&& session.detectionSource === "claude-hook"
|
|
203
|
+
&& !resolvesAttention(existing, session);
|
|
170
204
|
|
|
171
|
-
if (
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
235
|
+
if (session.state === "waiting-input" || session.state === "error") {
|
|
236
|
+
session = { ...session, lastWorkingChangedAt: undefined, lastChildActiveAt: undefined };
|
|
237
|
+
}
|
|
185
238
|
}
|
|
186
239
|
}
|
|
187
240
|
}
|
package/src/service/manager.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
package/src/usage/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
];
|
|
32
|
-
})();
|
|
138
|
+
// Linux
|
|
139
|
+
if (!entry.linuxPath) return "";
|
|
140
|
+
return join(home, ".config", entry.linuxPath);
|
|
141
|
+
}
|
|
33
142
|
|
|
34
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
if (!
|
|
269
|
+
const rootPath = chromiumRootPath(entry);
|
|
270
|
+
if (!rootPath || !existsSync(rootPath)) return cookies;
|
|
87
271
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
+
}
|