@scrylog/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +836 -0
  3. package/package.json +75 -0
  4. package/src/api/docs/sessions.keys.ts +6 -0
  5. package/src/api/docs/sessions.ts +81 -0
  6. package/src/api/docs/system.keys.ts +5 -0
  7. package/src/api/docs/system.ts +66 -0
  8. package/src/api/docs/usage.keys.ts +3 -0
  9. package/src/api/docs/usage.ts +24 -0
  10. package/src/api/docs/webhooks.keys.ts +5 -0
  11. package/src/api/docs/webhooks.ts +78 -0
  12. package/src/api/index.ts +18 -0
  13. package/src/api/lib/auth.ts +43 -0
  14. package/src/api/lib/errors.ts +28 -0
  15. package/src/api/lib/route.ts +18 -0
  16. package/src/api/models/claude.ts +4 -0
  17. package/src/api/models/codex.ts +4 -0
  18. package/src/api/models/opencode.ts +11 -0
  19. package/src/api/routes/sessions.ts +47 -0
  20. package/src/api/routes/system.ts +103 -0
  21. package/src/api/routes/usage.ts +15 -0
  22. package/src/api/routes/webhooks/claude.ts +161 -0
  23. package/src/api/routes/webhooks/codex.ts +175 -0
  24. package/src/api/routes/webhooks/opencode.ts +107 -0
  25. package/src/api/routes.ts +93 -0
  26. package/src/api/schemas/common.ts +21 -0
  27. package/src/api/schemas/sessions.ts +72 -0
  28. package/src/api/schemas/usage.ts +50 -0
  29. package/src/api/schemas/webhooks/claude.ts +32 -0
  30. package/src/api/schemas/webhooks/codex.ts +34 -0
  31. package/src/api/schemas/webhooks/opencode.ts +29 -0
  32. package/src/api/services/claude-session.ts +9 -0
  33. package/src/api/services/codex-session.ts +8 -0
  34. package/src/api/services/opencode-session.ts +73 -0
  35. package/src/classifier.ts +206 -0
  36. package/src/cli/commands.ts +498 -0
  37. package/src/cli/prompt.ts +44 -0
  38. package/src/daemon/cli.ts +27 -0
  39. package/src/daemon/collectors/claude/README.md +108 -0
  40. package/src/daemon/collectors/claude/collector.ts +413 -0
  41. package/src/daemon/collectors/claude/discovery.ts +148 -0
  42. package/src/daemon/collectors/claude/hooks.ts +181 -0
  43. package/src/daemon/collectors/claude/index.ts +8 -0
  44. package/src/daemon/collectors/claude/jsonl.ts +85 -0
  45. package/src/daemon/collectors/claude/transcript.ts +246 -0
  46. package/src/daemon/collectors/codex/README.md +147 -0
  47. package/src/daemon/collectors/codex/collector.ts +661 -0
  48. package/src/daemon/collectors/codex/discovery.ts +104 -0
  49. package/src/daemon/collectors/codex/hooks.ts +179 -0
  50. package/src/daemon/collectors/codex/index.ts +7 -0
  51. package/src/daemon/collectors/codex/jsonl.ts +108 -0
  52. package/src/daemon/collectors/codex/transcript.ts +299 -0
  53. package/src/daemon/collectors/git.ts +295 -0
  54. package/src/daemon/collectors/opencode/README.md +106 -0
  55. package/src/daemon/collectors/opencode/collector.ts +543 -0
  56. package/src/daemon/collectors/opencode/discovery.ts +257 -0
  57. package/src/daemon/collectors/opencode/index.ts +5 -0
  58. package/src/daemon/collectors/opencode/session-builder.ts +59 -0
  59. package/src/daemon/collectors/opencode-db.ts +282 -0
  60. package/src/daemon/collectors/process.ts +201 -0
  61. package/src/daemon/collectors/tmux/README.md +128 -0
  62. package/src/daemon/collectors/tmux/collector.ts +472 -0
  63. package/src/daemon/collectors/tmux/enrichment.ts +241 -0
  64. package/src/daemon/collectors/tmux/index.ts +4 -0
  65. package/src/daemon/collectors/tmux/parse.ts +128 -0
  66. package/src/daemon/collectors/usage.ts +193 -0
  67. package/src/daemon/index.ts +207 -0
  68. package/src/daemon/scheduler.ts +86 -0
  69. package/src/daemon/store.ts +535 -0
  70. package/src/detect.ts +143 -0
  71. package/src/hooks/claude.ts +596 -0
  72. package/src/hooks/codex.ts +517 -0
  73. package/src/legacy/classifier.ts +215 -0
  74. package/src/legacy/detect.ts +143 -0
  75. package/src/mcp/client-server.ts +21 -0
  76. package/src/mcp/http-handler.ts +126 -0
  77. package/src/mcp/index.ts +61 -0
  78. package/src/mcp/resources/config-current.ts +19 -0
  79. package/src/mcp/resources/index.ts +11 -0
  80. package/src/mcp/resources/sessions-active.ts +19 -0
  81. package/src/mcp/resources/sessions-all.ts +19 -0
  82. package/src/mcp/schemas/sessions.ts +3 -0
  83. package/src/mcp/server.ts +17 -0
  84. package/src/mcp/services/api-adapter.ts +48 -0
  85. package/src/mcp/services/data-source.ts +22 -0
  86. package/src/mcp/services/store-adapter.ts +40 -0
  87. package/src/mcp/tools/get-active-sessions.ts +16 -0
  88. package/src/mcp/tools/get-session-children.ts +24 -0
  89. package/src/mcp/tools/get-session-messages.ts +24 -0
  90. package/src/mcp/tools/get-session.ts +23 -0
  91. package/src/mcp/tools/index.ts +15 -0
  92. package/src/mcp/tools/list-sessions.ts +16 -0
  93. package/src/neo-blessed.d.ts +4 -0
  94. package/src/oc/index.ts +302 -0
  95. package/src/service/manager.ts +242 -0
  96. package/src/shared/config.ts +478 -0
  97. package/src/shared/ipc.ts +80 -0
  98. package/src/shared/logger.ts +248 -0
  99. package/src/shared/paths.ts +73 -0
  100. package/src/shared/platform.ts +110 -0
  101. package/src/shared/session-view.ts +241 -0
  102. package/src/shared/types.ts +299 -0
  103. package/src/shared/utils.ts +339 -0
  104. package/src/tui/app.ts +920 -0
  105. package/src/tui/client.ts +149 -0
  106. package/src/tui/index.ts +181 -0
  107. package/src/types.ts +113 -0
  108. package/src/usage/README.md +323 -0
  109. package/src/usage/api.ts +99 -0
  110. package/src/usage/client.ts +3 -0
  111. package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
  112. package/src/usage/clients/anthropic-web-usage.ts +245 -0
  113. package/src/usage/clients/claude-cli-probe.ts +187 -0
  114. package/src/usage/clients/codex-oauth-usage.ts +113 -0
  115. package/src/usage/clients/opencode-go-usage.ts +441 -0
  116. package/src/usage/index.ts +19 -0
  117. package/src/usage/mappers.ts +273 -0
  118. package/src/usage/planner.ts +54 -0
  119. package/src/usage/providers/claude.ts +113 -0
  120. package/src/usage/providers/codex.ts +34 -0
  121. package/src/usage/providers/opencode-go.ts +37 -0
  122. package/src/usage/resolver.ts +4 -0
  123. package/src/usage/resolvers/browser-cookies.ts +198 -0
  124. package/src/usage/resolvers/claude-auth.ts +99 -0
  125. package/src/usage/resolvers/claude-cli.ts +24 -0
  126. package/src/usage/resolvers/claude-web.ts +16 -0
  127. package/src/usage/resolvers/codex-auth.ts +117 -0
  128. package/src/usage/resolvers/opencode-go-web.ts +14 -0
  129. package/src/usage/types.ts +190 -0
@@ -0,0 +1,498 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { loadConfig, getResolvedConfigPath, updateConfig } from "../shared/config";
7
+ import type { ScrylogConfig } from "../shared/config";
8
+ import { getDaemonLogPath } from "../shared/paths";
9
+ import { createPromptSession } from "./prompt";
10
+ import { getServiceManagerOrThrow } from "../service/manager";
11
+ import { createDaemonFetch, getSocketPath } from "../shared/ipc";
12
+ import { IS_WINDOWS, whichCommand } from "../shared/platform";
13
+
14
+ export interface GlobalCliOptions {
15
+ configPath?: string;
16
+ }
17
+
18
+ export async function runDaemonCommand(action: string, options: GlobalCliOptions): Promise<void> {
19
+ const manager = getServiceManagerOrThrow();
20
+ if (action === "install") {
21
+ manager.install(options.configPath);
22
+ process.stdout.write("Installed daemon user service.\n");
23
+ return;
24
+ }
25
+ if (action === "uninstall") {
26
+ manager.uninstall();
27
+ process.stdout.write("Uninstalled daemon user service.\n");
28
+ return;
29
+ }
30
+ if (action === "start") {
31
+ manager.start();
32
+ process.stdout.write("Started daemon user service.\n");
33
+ return;
34
+ }
35
+ if (action === "stop") {
36
+ manager.stop();
37
+ process.stdout.write("Stopped daemon user service.\n");
38
+ return;
39
+ }
40
+ if (action === "restart") {
41
+ manager.restart();
42
+ process.stdout.write("Restarted daemon user service.\n");
43
+ return;
44
+ }
45
+ if (action === "status") {
46
+ const status = manager.status();
47
+ process.stdout.write(`Platform: ${status.platform}\nInstalled: ${status.installed}\nRunning: ${status.running}\n${status.detail}\n`);
48
+ return;
49
+ }
50
+ throw new Error(`Unknown daemon action: ${action}`);
51
+ }
52
+
53
+ export function runConfigCommand(action: string, options: GlobalCliOptions): void {
54
+ if (action === "path") {
55
+ process.stdout.write(`${getResolvedConfigPath(options.configPath)}\n`);
56
+ return;
57
+ }
58
+ if (action === "show") {
59
+ process.stdout.write(`${JSON.stringify(loadConfig(options.configPath), null, 2)}\n`);
60
+ return;
61
+ }
62
+ throw new Error(`Unknown config action: ${action}`);
63
+ }
64
+
65
+ export function runTokenCommand(target: "mcp" | "api" | "both", action: string, options: GlobalCliOptions): void {
66
+ if (action === "clear") {
67
+ updateConfig((current) => {
68
+ const next: ScrylogConfig = { ...current, mcp: { ...current.mcp }, api: { ...current.api } };
69
+ if (target === "mcp" || target === "both") next.mcp.bearerToken = undefined;
70
+ if (target === "api" || target === "both") next.api.bearerToken = undefined;
71
+ return next;
72
+ }, options.configPath);
73
+ process.stdout.write(`Cleared ${target === "both" ? "API and MCP" : target.toUpperCase()} bearer token.\n`);
74
+ return;
75
+ }
76
+
77
+ if (action === "generate" || action === "rotate") {
78
+ const token = generateToken();
79
+ updateConfig((current) => {
80
+ const next: ScrylogConfig = { ...current, mcp: { ...current.mcp }, api: { ...current.api } };
81
+ if (target === "mcp" || target === "both") next.mcp.bearerToken = token;
82
+ if (target === "api" || target === "both") next.api.bearerToken = token;
83
+ return next;
84
+ }, options.configPath);
85
+ process.stdout.write(`${token}\n`);
86
+ return;
87
+ }
88
+
89
+ throw new Error(`Unknown token action: ${action}`);
90
+ }
91
+
92
+ export async function runLogsCommand(): Promise<void> {
93
+ const logPath = getDaemonLogPath();
94
+ process.stdout.write(`Log path: ${logPath}\n`);
95
+ if (!existsSync(logPath)) {
96
+ process.stdout.write("No daemon log file found.\n");
97
+ return;
98
+ }
99
+ const content = await readFile(logPath, "utf8");
100
+ const lines = content.trim().split(/\r?\n/);
101
+ const recent = lines.slice(Math.max(0, lines.length - 40));
102
+ process.stdout.write(`${recent.join("\n")}\n`);
103
+ }
104
+
105
+ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
106
+ const configPath = getResolvedConfigPath(options.configPath);
107
+ const config = loadConfig(options.configPath);
108
+ const daemonFetch = createDaemonFetch({
109
+ host: config.api.host,
110
+ port: config.api.port,
111
+ socketPath: IS_WINDOWS ? undefined : getSocketPath(config.daemon.socketPath),
112
+ });
113
+
114
+ process.stdout.write(`Config path: ${configPath}\n`);
115
+ process.stdout.write(`Config exists: ${existsSync(configPath)}\n`);
116
+ process.stdout.write(`Socket path: ${IS_WINDOWS ? "n/a" : getSocketPath(config.daemon.socketPath)}\n`);
117
+ process.stdout.write(`Log path: ${config.daemon.logFilePath}\n`);
118
+ process.stdout.write(`Log file enabled: ${config.daemon.logFileEnabled}\n`);
119
+ process.stdout.write(`Command timeout ms: ${config.daemon.commandTimeoutMs}\n`);
120
+ process.stdout.write(`Network timeout ms: ${config.daemon.networkTimeoutMs}\n`);
121
+ process.stdout.write(`Retention max sessions: ${config.retention.maxSessions}\n`);
122
+ process.stdout.write(`Retention standalone max age hours: ${config.retention.standaloneMaxAgeHours}\n`);
123
+ process.stdout.write(`Usage enabled: ${config.usage.enabled}\n`);
124
+
125
+ try {
126
+ const health = await daemonFetch("/api/health");
127
+ process.stdout.write(`Local daemon reachable: ${health.ok}\n`);
128
+ } catch {
129
+ process.stdout.write("Local daemon reachable: false\n");
130
+ }
131
+
132
+ process.stdout.write(`TCP API enabled: ${config.api.tcpEnabled}\n`);
133
+ process.stdout.write(`API bearer token configured: ${Boolean(config.api.bearerToken)}\n`);
134
+ if (config.api.tcpEnabled) {
135
+ try {
136
+ const response = await fetch(`http://${config.api.host}:${config.api.port}/api/health`);
137
+ process.stdout.write(`TCP API reachable: ${response.ok}\n`);
138
+ } catch {
139
+ process.stdout.write("TCP API reachable: false\n");
140
+ }
141
+ if (config.api.bearerToken) {
142
+ try {
143
+ const response = await fetch(`http://${config.api.host}:${config.api.port}/api/status`, {
144
+ headers: { Authorization: `Bearer ${config.api.bearerToken}` },
145
+ });
146
+ process.stdout.write(`TCP API auth test: ${response.ok}\n`);
147
+ } catch {
148
+ process.stdout.write("TCP API auth test: false\n");
149
+ }
150
+ }
151
+ }
152
+
153
+ process.stdout.write(`CORS enabled: ${config.cors.enabled}\n`);
154
+ if (config.cors.enabled) {
155
+ process.stdout.write(`CORS origins: ${config.cors.origins.join(", ")}\n`);
156
+ }
157
+
158
+ process.stdout.write(`MCP HTTP enabled: ${config.mcp.httpEnabled}\n`);
159
+ if (config.mcp.httpEnabled) {
160
+ try {
161
+ const response = await fetch(`http://${config.mcp.host}:${config.mcp.port}`, {
162
+ method: "POST",
163
+ headers: {
164
+ Accept: "application/json, text/event-stream",
165
+ "Content-Type": "application/json",
166
+ ...(config.mcp.bearerToken ? { Authorization: `Bearer ${config.mcp.bearerToken}` } : {}),
167
+ },
168
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping", params: {} }),
169
+ });
170
+ process.stdout.write(`MCP HTTP reachable: ${response.status >= 200 && response.status < 500}\n`);
171
+ process.stdout.write(`MCP bearer token configured: ${Boolean(config.mcp.bearerToken)}\n`);
172
+ } catch {
173
+ process.stdout.write("MCP HTTP reachable: false\n");
174
+ }
175
+ }
176
+
177
+ try {
178
+ const manager = getServiceManagerOrThrow();
179
+ const status = manager.status();
180
+ process.stdout.write(`Service platform: ${status.platform}\nInstalled: ${status.installed}\nRunning: ${status.running}\n`);
181
+ } catch (error) {
182
+ process.stdout.write(`Service manager: ${error instanceof Error ? error.message : String(error)}\n`);
183
+ }
184
+
185
+ const tmux = Bun.spawnSync(["tmux", "-V"], { stdout: "pipe", stderr: "pipe" });
186
+ process.stdout.write(`tmux available: ${tmux.exitCode === 0}\n`);
187
+
188
+ if (config.opencode.mode === "hooks") {
189
+ process.stdout.write("OpenCode mode: hooks\n");
190
+ process.stdout.write(`OpenCode plugin config present: ${existsSync(join(homedir(), ".config", "opencode", "opencode.json"))}\n`);
191
+ } else {
192
+ process.stdout.write(`OpenCode mode: sse\nOpenCode auto-discover: ${config.opencode.autoDiscover}\nConfigured instances: ${config.opencode.instances.length}\n`);
193
+ }
194
+
195
+ process.stdout.write(`Claude hooks enabled: ${config.claude.hooksEnabled}\n`);
196
+ if (config.claude.hooksEnabled) {
197
+ const claudeSettingsPath = join(homedir(), ".claude", "settings.json");
198
+ process.stdout.write(`Claude settings config present: ${existsSync(claudeSettingsPath)}\n`);
199
+ }
200
+
201
+ process.stdout.write(`Codex hooks enabled: ${config.codex.hooksEnabled}\n`);
202
+ if (config.codex.hooksEnabled) {
203
+ const codexHooksPath = join(homedir(), ".codex", "hooks.json");
204
+ process.stdout.write(`Codex hooks config present: ${existsSync(codexHooksPath)}\n`);
205
+ }
206
+ }
207
+
208
+ export async function runSetupWizard(options: GlobalCliOptions): Promise<void> {
209
+ const prompt = createPromptSession();
210
+ try {
211
+ const resolvedPath = getResolvedConfigPath(options.configPath);
212
+ const current = loadConfig(options.configPath);
213
+ process.stdout.write(`Config path: ${resolvedPath}\n`);
214
+
215
+ const configureOpenCode = await prompt.yesNo("Configure OpenCode now?", false);
216
+ let next: ScrylogConfig = { ...current, api: { ...current.api }, mcp: { ...current.mcp }, opencode: { ...current.opencode }, daemon: { ...current.daemon }, cors: { ...current.cors }, retention: { ...current.retention }, usage: { ...current.usage } };
217
+
218
+ if (configureOpenCode) {
219
+ const mode = await prompt.choose("Select OpenCode mode", ["hooks", "sse"], current.opencode.mode === "hooks" ? 0 : 1);
220
+ next.opencode.mode = mode as "hooks" | "sse";
221
+ if (mode === "sse") {
222
+ next.opencode.autoDiscover = await prompt.yesNo("Enable OpenCode auto-discovery?", current.opencode.autoDiscover);
223
+ const instances = await prompt.text("Comma-separated OpenCode instance URLs", current.opencode.instances.join(","));
224
+ next.opencode.instances = instances
225
+ .split(",")
226
+ .map((value) => value.trim())
227
+ .filter(Boolean);
228
+ } else {
229
+ process.stdout.write("Hooks mode requires the @scrylog/opencode-plugin plugin in your OpenCode config.\n");
230
+ }
231
+ }
232
+
233
+ const pollInterval = await prompt.text("Daemon poll interval (ms)", String(current.daemon.pollIntervalMs));
234
+ const logLevel = await prompt.choose("Daemon log level", ["debug", "info", "warn", "error"], ["debug", "info", "warn", "error"].indexOf(current.daemon.logLevel));
235
+ next.daemon.pollIntervalMs = parseOptionalInt(pollInterval, current.daemon.pollIntervalMs);
236
+ next.daemon.logLevel = logLevel as ScrylogConfig["daemon"]["logLevel"];
237
+ next.daemon.commandTimeoutMs = parseOptionalInt(await prompt.text("Command timeout (ms)", String(current.daemon.commandTimeoutMs)), current.daemon.commandTimeoutMs);
238
+ next.daemon.networkTimeoutMs = parseOptionalInt(await prompt.text("Network timeout (ms)", String(current.daemon.networkTimeoutMs)), current.daemon.networkTimeoutMs);
239
+ const socketPath = await prompt.text("Unix socket path (blank = default)", current.daemon.socketPath ?? "");
240
+ next.daemon.socketPath = socketPath.trim() || undefined;
241
+
242
+ next.retention.maxSessions = parseOptionalInt(await prompt.text("Retention max sessions", String(current.retention.maxSessions)), current.retention.maxSessions);
243
+ next.retention.standaloneMaxAgeHours = parseOptionalInt(await prompt.text("Retention standalone max age (hours, 0 = disabled)", String(current.retention.standaloneMaxAgeHours)), current.retention.standaloneMaxAgeHours);
244
+ next.usage.enabled = await prompt.yesNo("Enable usage collection?", current.usage.enabled);
245
+
246
+ next.api.tcpEnabled = await prompt.yesNo("Enable TCP API listener?", current.api.tcpEnabled);
247
+ if (next.api.tcpEnabled) {
248
+ next.api.host = await prompt.text("TCP API host", current.api.host);
249
+ next.api.port = parseOptionalInt(await prompt.text("TCP API port", String(current.api.port)), current.api.port);
250
+ const tokenAction = await prompt.choose("API bearer token", ["generate", "keep current", "clear"], current.api.bearerToken ? 1 : 0);
251
+ if (tokenAction === "generate") {
252
+ next.api.bearerToken = generateToken();
253
+ process.stdout.write(`Generated API token: ${next.api.bearerToken}\n`);
254
+ } else if (tokenAction === "clear") {
255
+ next.api.bearerToken = undefined;
256
+ }
257
+ }
258
+
259
+ next.cors.enabled = await prompt.yesNo("Enable CORS for browser access?", current.cors.enabled);
260
+ if (next.cors.enabled) {
261
+ const originsRaw = await prompt.text("Allowed origins (comma-separated, or * for all)", current.cors.origins.join(",") || "*");
262
+ next.cors.origins = originsRaw === "*"
263
+ ? ["*"]
264
+ : originsRaw.split(",").map((s) => s.trim()).filter(Boolean);
265
+ next.cors.credentials = await prompt.yesNo("Allow credentials in CORS requests?", current.cors.credentials);
266
+ }
267
+
268
+ next.mcp.httpEnabled = await prompt.yesNo("Enable MCP HTTP listener?", current.mcp.httpEnabled);
269
+ if (next.mcp.httpEnabled) {
270
+ next.mcp.host = await prompt.text("MCP HTTP host", current.mcp.host);
271
+ next.mcp.port = parseOptionalInt(await prompt.text("MCP HTTP port", String(current.mcp.port)), current.mcp.port);
272
+ const tokenAction = await prompt.choose("MCP token", ["generate", "keep current", "clear"], current.mcp.bearerToken ? 1 : 0);
273
+ if (tokenAction === "generate") {
274
+ next.mcp.bearerToken = generateToken();
275
+ process.stdout.write(`Generated MCP token: ${next.mcp.bearerToken}\n`);
276
+ } else if (tokenAction === "clear") {
277
+ next.mcp.bearerToken = undefined;
278
+ }
279
+ }
280
+
281
+ updateConfig(() => next, options.configPath);
282
+ process.stdout.write(`Wrote config to ${resolvedPath}\n`);
283
+
284
+ const installService = await prompt.yesNo("Install daemon as a user service?", true);
285
+ if (installService) {
286
+ const manager = getServiceManagerOrThrow();
287
+ manager.install(options.configPath);
288
+ process.stdout.write("Installed daemon service.\n");
289
+ if (await prompt.yesNo("Start or restart the service now?", true)) {
290
+ manager.restart();
291
+ process.stdout.write("Service restarted.\n");
292
+ }
293
+ }
294
+
295
+ await runDoctor(options);
296
+ } finally {
297
+ prompt.close();
298
+ }
299
+ }
300
+
301
+ export function generateToken(): string {
302
+ return randomBytes(24).toString("base64url");
303
+ }
304
+
305
+ function parseOptionalInt(value: string, fallback: number): number {
306
+ const parsed = Number.parseInt(value, 10);
307
+ return Number.isNaN(parsed) ? fallback : parsed;
308
+ }
309
+
310
+ // ── Claude hooks setup ────────────────────────────────────────────────────────
311
+
312
+ const CLAUDE_HOOK_EVENTS = [
313
+ "SessionStart",
314
+ "SessionEnd",
315
+ "UserPromptSubmit",
316
+ "PreToolUse",
317
+ "PermissionRequest",
318
+ "PermissionDenied",
319
+ "PostToolUse",
320
+ "PostToolUseFailure",
321
+ "PostToolBatch",
322
+ "Stop",
323
+ "StopFailure",
324
+ "SubagentStart",
325
+ "SubagentStop",
326
+ "CwdChanged",
327
+ ] as const;
328
+
329
+ interface ClaudeSettingsHookEntry {
330
+ type: "command";
331
+ command: string;
332
+ }
333
+
334
+ interface ClaudeSettingsHookRule {
335
+ matcher: string;
336
+ hooks: ClaudeSettingsHookEntry[];
337
+ }
338
+
339
+ interface ClaudeSettings {
340
+ hooks?: Record<string, ClaudeSettingsHookRule[]>;
341
+ [key: string]: unknown;
342
+ }
343
+
344
+ export async function runClaudeHooksInstall(options: GlobalCliOptions): Promise<void> {
345
+ const claudeDir = join(homedir(), ".claude");
346
+ const settingsPath = join(claudeDir, "settings.json");
347
+
348
+ // Verify scrylog-claude-hook is in PATH
349
+ const hookBin = whichCommand("scrylog-claude-hook") ?? "scrylog-claude-hook";
350
+ const hookBinFound = hookBin !== "scrylog-claude-hook";
351
+
352
+ if (!hookBinFound) {
353
+ process.stderr.write(
354
+ "Warning: scrylog-claude-hook not found in PATH. Make sure it is installed before Claude Code runs hooks.\n",
355
+ );
356
+ }
357
+
358
+ // Load or create settings.json
359
+ let settings: ClaudeSettings = {};
360
+ if (existsSync(settingsPath)) {
361
+ try {
362
+ settings = JSON.parse(readFileSync(settingsPath, "utf8")) as ClaudeSettings;
363
+ } catch {
364
+ process.stderr.write(`Warning: could not parse ${settingsPath} — starting with empty settings.\n`);
365
+ }
366
+ } else {
367
+ mkdirSync(claudeDir, { recursive: true });
368
+ }
369
+
370
+ // Merge hooks — add scrylog entries without removing existing ones
371
+ const existingHooks = settings.hooks ?? {};
372
+ const updatedHooks: Record<string, ClaudeSettingsHookRule[]> = { ...existingHooks };
373
+
374
+ for (const event of CLAUDE_HOOK_EVENTS) {
375
+ const existing = updatedHooks[event] ?? [];
376
+ const alreadyInstalled = existing.some((rule) =>
377
+ rule.hooks.some((h) => h.command === hookBin || h.command === "scrylog-claude-hook"),
378
+ );
379
+ if (!alreadyInstalled) {
380
+ updatedHooks[event] = [
381
+ ...existing,
382
+ { matcher: "", hooks: [{ type: "command", command: hookBin }] },
383
+ ];
384
+ }
385
+ }
386
+
387
+ settings.hooks = updatedHooks;
388
+
389
+ // Write atomically
390
+ const tmp = `${settingsPath}.tmp`;
391
+ writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
392
+ renameSync(tmp, settingsPath);
393
+
394
+ process.stdout.write(`Wrote Claude hooks to ${settingsPath}\n`);
395
+ process.stdout.write(`Hooks installed: ${CLAUDE_HOOK_EVENTS.join(", ")}\n`);
396
+ process.stdout.write(`Hook command: ${hookBin}\n`);
397
+
398
+ // Enable hooksEnabled in scrylog config
399
+ const { path: configPath } = updateConfig(
400
+ (current) => ({ ...current, claude: { ...current.claude, hooksEnabled: true } }),
401
+ options.configPath,
402
+ );
403
+ process.stdout.write(`Enabled claude.hooksEnabled in scrylog config: ${configPath}\n`);
404
+ process.stdout.write("Done. Restart the scrylog daemon to apply changes.\n");
405
+ }
406
+
407
+ // ── Codex hooks setup ─────────────────────────────────────────────────────────
408
+
409
+ const CODEX_HOOK_EVENTS = [
410
+ "SessionStart",
411
+ "PreToolUse",
412
+ "PermissionRequest",
413
+ "PostToolUse",
414
+ "UserPromptSubmit",
415
+ "SubagentStart",
416
+ "SubagentStop",
417
+ "Stop",
418
+ ] as const;
419
+
420
+ interface CodexHookEntry {
421
+ type: "command";
422
+ command: string;
423
+ timeout?: number;
424
+ }
425
+
426
+ interface CodexHookRule {
427
+ matcher?: string;
428
+ hooks: CodexHookEntry[];
429
+ }
430
+
431
+ interface CodexHooksConfig {
432
+ hooks?: Record<string, CodexHookRule[]>;
433
+ [key: string]: unknown;
434
+ }
435
+
436
+ export async function runCodexHooksInstall(options: GlobalCliOptions): Promise<void> {
437
+ const codexDir = process.env.CODEX_HOME ? process.env.CODEX_HOME : join(homedir(), ".codex");
438
+ const hooksPath = join(codexDir, "hooks.json");
439
+
440
+ // Verify scrylog-codex-hook is in PATH
441
+ const hookBin = whichCommand("scrylog-codex-hook") ?? "scrylog-codex-hook";
442
+ const hookBinFound = hookBin !== "scrylog-codex-hook";
443
+
444
+ if (!hookBinFound) {
445
+ process.stderr.write(
446
+ "Warning: scrylog-codex-hook not found in PATH. Make sure it is installed before Codex CLI runs hooks.\n",
447
+ );
448
+ }
449
+
450
+ // Load or create hooks.json
451
+ let config: CodexHooksConfig = {};
452
+ if (existsSync(hooksPath)) {
453
+ try {
454
+ config = JSON.parse(readFileSync(hooksPath, "utf8")) as CodexHooksConfig;
455
+ } catch {
456
+ process.stderr.write(`Warning: could not parse ${hooksPath} — starting with empty config.\n`);
457
+ }
458
+ } else {
459
+ mkdirSync(codexDir, { recursive: true });
460
+ }
461
+
462
+ // Merge hooks — add scrylog entries without removing existing ones
463
+ const existingHooks = config.hooks ?? {};
464
+ const updatedHooks: Record<string, CodexHookRule[]> = { ...existingHooks };
465
+
466
+ for (const event of CODEX_HOOK_EVENTS) {
467
+ const existing = updatedHooks[event] ?? [];
468
+ const alreadyInstalled = existing.some((rule) =>
469
+ rule.hooks.some((h) => h.command === hookBin || h.command === "scrylog-codex-hook"),
470
+ );
471
+ if (!alreadyInstalled) {
472
+ const entry: CodexHookRule = {
473
+ matcher: event === "UserPromptSubmit" || event === "Stop" ? undefined : "",
474
+ hooks: [{ type: "command", command: hookBin, ...(event === "Stop" ? { timeout: 30 } : {}) }],
475
+ };
476
+ updatedHooks[event] = [...existing, entry];
477
+ }
478
+ }
479
+
480
+ config.hooks = updatedHooks;
481
+
482
+ // Write atomically
483
+ const tmp = `${hooksPath}.tmp`;
484
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
485
+ renameSync(tmp, hooksPath);
486
+
487
+ process.stdout.write(`Wrote Codex hooks to ${hooksPath}\n`);
488
+ process.stdout.write(`Hooks installed: ${CODEX_HOOK_EVENTS.join(", ")}\n`);
489
+ process.stdout.write(`Hook command: ${hookBin}\n`);
490
+
491
+ // Enable hooksEnabled in scrylog config
492
+ const { path: configPath } = updateConfig(
493
+ (current) => ({ ...current, codex: { ...current.codex, hooksEnabled: true } }),
494
+ options.configPath,
495
+ );
496
+ process.stdout.write(`Enabled codex.hooksEnabled in scrylog config: ${configPath}\n`);
497
+ process.stdout.write("Done. Restart the scrylog daemon to apply changes.\n");
498
+ }
@@ -0,0 +1,44 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+
4
+ export interface PromptSession {
5
+ text(message: string, defaultValue?: string): Promise<string>;
6
+ yesNo(message: string, defaultValue?: boolean): Promise<boolean>;
7
+ choose(message: string, options: string[], defaultIndex?: number): Promise<string>;
8
+ close(): void;
9
+ }
10
+
11
+ export function createPromptSession(): PromptSession {
12
+ const rl = createInterface({ input, output });
13
+
14
+ return {
15
+ async text(message: string, defaultValue?: string): Promise<string> {
16
+ const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : "";
17
+ const answer = (await rl.question(`${message}${suffix}: `)).trim();
18
+ return answer || defaultValue || "";
19
+ },
20
+ async yesNo(message: string, defaultValue = true): Promise<boolean> {
21
+ const label = defaultValue ? "Y/n" : "y/N";
22
+ const answer = (await rl.question(`${message} [${label}]: `)).trim().toLowerCase();
23
+ if (!answer) return defaultValue;
24
+ return answer === "y" || answer === "yes";
25
+ },
26
+ async choose(message: string, options: string[], defaultIndex = 0): Promise<string> {
27
+ output.write(`${message}\n`);
28
+ for (const [index, option] of options.entries()) {
29
+ output.write(` ${index + 1}. ${option}\n`);
30
+ }
31
+ while (true) {
32
+ const answer = (await rl.question(`Select [${defaultIndex + 1}]: `)).trim();
33
+ const value = answer || String(defaultIndex + 1);
34
+ const index = Number.parseInt(value, 10) - 1;
35
+ if (!Number.isNaN(index) && index >= 0 && index < options.length) {
36
+ return options[index]!;
37
+ }
38
+ }
39
+ },
40
+ close(): void {
41
+ rl.close();
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,27 @@
1
+ export interface DaemonCliOptions {
2
+ help: boolean;
3
+ configPath?: string;
4
+ logLevel?: string;
5
+ logFormat?: "pretty" | "json";
6
+ logFileEnabled: boolean;
7
+ }
8
+
9
+ export function parseDaemonArgs(args: string[]): DaemonCliOptions {
10
+ const configFlagIndex = args.indexOf("--config");
11
+ const logLevelFlagIndex = args.indexOf("--log-level");
12
+ const logFormatFlagIndex = args.indexOf("--log-format");
13
+ const rawLogFormat = logFormatFlagIndex >= 0 ? args[logFormatFlagIndex + 1] : undefined;
14
+ const logFormat = rawLogFormat === "pretty" || rawLogFormat === "json" ? rawLogFormat : undefined;
15
+
16
+ if (logFormatFlagIndex >= 0 && rawLogFormat !== "pretty" && rawLogFormat !== "json") {
17
+ throw new Error("--log-format must be 'pretty' or 'json'");
18
+ }
19
+
20
+ return {
21
+ help: args.includes("--help") || args.includes("-h"),
22
+ configPath: configFlagIndex >= 0 ? args[configFlagIndex + 1] : undefined,
23
+ logLevel: logLevelFlagIndex >= 0 ? args[logLevelFlagIndex + 1] : undefined,
24
+ logFormat,
25
+ logFileEnabled: !args.includes("--no-log-file"),
26
+ };
27
+ }
@@ -0,0 +1,108 @@
1
+ # Claude Collector
2
+
3
+ Monitors Claude Code interactive sessions by reading JSONL transcript files and
4
+ optional hook files emitted by the `scrylog-claude-hook` binary.
5
+
6
+ ## Data Flow
7
+
8
+ ```
9
+ ~/.claude/projects/<slug>/<sessionId>.jsonl ~/.claude/hooks/<sessionId>.json
10
+ │ │
11
+ │ │
12
+ ▼ ▼
13
+ ┌──────────────┐ ┌──────────────┐
14
+ │ discovery │ discoverTranscripts() │ hooks │ readHookData()
15
+ │ │ │ │
16
+ └──────┬───────┘ └──────┬───────┘
17
+ │ │
18
+ │ │
19
+ ▼ │
20
+ ┌──────────────┐ │
21
+ │ jsonl │ parseJsonlFile() / parseJsonlHead() │
22
+ └──────┬───────┘ │
23
+ │ │
24
+ ▼ │
25
+ ┌──────────────┐ │
26
+ │ transcript │ inferState() │
27
+ │ │ extractSummary() │
28
+ │ │ extractTitle() │
29
+ │ │ isLocalCommandTranscript() │
30
+ └──────┬───────┘ │
31
+ │ │
32
+ ▼ ▼
33
+ ┌──────────────────────────────────────────────────────────────┐
34
+ │ collector.ts │
35
+ │ │
36
+ │ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
37
+ │ │ 30s poll │ │ watchFile │ │ fs.watch │ │
38
+ │ │ run() │ │ (2s interval)│ │ hooksDir │ │
39
+ │ └──────┬───────┘ └──────┬───────┘ └────┬─────┘ │
40
+ │ │ │ │ │
41
+ │ │ │ │ │
42
+ │ └────────────┬────────┴────────────────────┘ │
43
+ │ │ │
44
+ │ ▼ │
45
+ │ processTranscript() │
46
+ │ │ │
47
+ │ │ ┌───────────────────────────────┐ │
48
+ │ └──│ mergeHookIntoModel() │ │
49
+ │ │ (if hook newer than transcript)│ │
50
+ │ └───────────────────────────────┘ │
51
+ │ │ │
52
+ │ ▼ │
53
+ │ store.upsertSession() │
54
+ │ store.syncChildSessionIds() │
55
+ └──────────────────────────────────────────────────────────────┘
56
+ │
57
+ ▼
58
+ DaemonStore ──► /api/events SSE ──► TUI
59
+ ```
60
+
61
+ ## Modules
62
+
63
+ | File | Responsibility | Key Exports |
64
+ |------|-------------|-------------|
65
+ | `discovery.ts` | Scan `~/.claude/projects/<slug>/` for `.jsonl` files; decode slug back to absolute path | `discoverTranscripts()`, `decodeClaudeProjectSlug()`, `getClaudeDir()`, `getClaudeProcessPids()` |
66
+ | `jsonl.ts` | Read JSONL tail/head with byte-size limits; parse lines into typed entries | `parseJsonlFile()`, `parseJsonlHead()`, `readTextTail()`, `readTextHead()`, `JsonlEntry`, `ContentBlock` |
67
+ | `transcript.ts` | Infer session state from the last meaningful turn; extract summary/title; detect headless `claude -p` transcripts | `inferState()`, `extractSummary()`, `extractTitle()`, `isLocalCommandTranscript()`, `isQuestionText()` |
68
+ | `hooks.ts` | Read/validate hook JSON files; merge hook data into a session model; merge claude metadata without overwriting existing fields | `readHookData()`, `mergeHookIntoModel()`, `mergeClaudeMeta()`, `isHookNewer()`, `getHookTime()`, `isValidHookData()`, `hasClaudePermissionPrompt()`, `ClaudeHookFileData` |
69
+ | `collector.ts` | Orchestrate discovery, transcript watching, hook watching, deduplication, and store writes | `ClaudeCollector` (implements `Collector`) |
70
+ | `index.ts` | Public re-exports | `ClaudeCollector`, test-accessible pure functions |
71
+
72
+ ## State Inference Rules (`transcript.ts`)
73
+
74
+ `inferState()` walks the JSONL entries in reverse order looking for the last
75
+ **meaningful** turn, skipping:
76
+ 1. Synthetic user entries (tool results, "[Request interrupted by user]")
77
+ 2. Thinking-only assistant entries
78
+ 3. System/tool events without a role
79
+
80
+ | Last meaningful turn | State | Reason |
81
+ |----------------------|-------|--------|
82
+ | `assistant` with question text | `waiting-input` | Claude is asking the user something |
83
+ | `assistant` with visible text, no tool_use | `idle` | Claude has replied |
84
+ | `assistant` with tool_use, no visible text | `working` | Claude is executing a tool |
85
+ | `user` (non-synthetic) | `working` | Waiting for Claude's reply |
86
+ | Empty / only synthetic entries | `idle` | Nothing meaningful happened |
87
+
88
+ ## Hook Merge Priority
89
+
90
+ When both transcript and hook data exist, the collector uses this priority:
91
+
92
+ 1. **Hook newer** (`hookSeq` or `hookEventTime` >= existing): hook data wins for `state`, `hasAttention`, `summary`, `activeTool`
93
+ 2. **Hook older**: transcript data wins (except `claude` metadata merged via `mergeClaudeMeta`)
94
+ 3. **Hook-managed session** (`detectionSource === "claude-hook"`): transcript can only fill missing metadata (e.g. `transcriptPath`), never override state
95
+
96
+ ## Interactions
97
+
98
+ | Component | How |
99
+ |-----------|-----|
100
+ | `GitCollector` | Resolves `currentPath` → `project` when hooks/transcript provide a directory |
101
+ | `DaemonStore` | Upserts sessions; clears old bindings via `syncChildSessionIds()` |
102
+ | `Scheduler` | Calls `start()` → sets up `30s` discovery interval + `5s` fallback re-read |
103
+ | `TUI` | Receives updates via SSE from the store |
104
+
105
+ ## Detection Sources Produced
106
+
107
+ - `"claude-transcript"` — session discovered from `.jsonl` file, state inferred from transcript content
108
+ - `"claude-hook"` — session discovered/enriched from hook JSON, state is authoritative from Claude Code directly