zozul-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 (139) hide show
  1. package/.env.example +44 -0
  2. package/.github/workflows/publish.yml +26 -0
  3. package/DEVELOPMENT.md +288 -0
  4. package/LICENSE +201 -0
  5. package/README.md +178 -0
  6. package/dist/cli/commands.d.ts +3 -0
  7. package/dist/cli/commands.d.ts.map +1 -0
  8. package/dist/cli/commands.js +307 -0
  9. package/dist/cli/commands.js.map +1 -0
  10. package/dist/cli/format.d.ts +5 -0
  11. package/dist/cli/format.d.ts.map +1 -0
  12. package/dist/cli/format.js +115 -0
  13. package/dist/cli/format.js.map +1 -0
  14. package/dist/context/index.d.ts +8 -0
  15. package/dist/context/index.d.ts.map +1 -0
  16. package/dist/context/index.js +37 -0
  17. package/dist/context/index.js.map +1 -0
  18. package/dist/dashboard/html.d.ts +17 -0
  19. package/dist/dashboard/html.d.ts.map +1 -0
  20. package/dist/dashboard/html.js +79 -0
  21. package/dist/dashboard/html.js.map +1 -0
  22. package/dist/dashboard/index.html +1245 -0
  23. package/dist/hooks/config.d.ts +19 -0
  24. package/dist/hooks/config.d.ts.map +1 -0
  25. package/dist/hooks/config.js +106 -0
  26. package/dist/hooks/config.js.map +1 -0
  27. package/dist/hooks/git.d.ts +6 -0
  28. package/dist/hooks/git.d.ts.map +1 -0
  29. package/dist/hooks/git.js +73 -0
  30. package/dist/hooks/git.js.map +1 -0
  31. package/dist/hooks/index.d.ts +4 -0
  32. package/dist/hooks/index.d.ts.map +1 -0
  33. package/dist/hooks/index.js +3 -0
  34. package/dist/hooks/index.js.map +1 -0
  35. package/dist/hooks/server.d.ts +16 -0
  36. package/dist/hooks/server.d.ts.map +1 -0
  37. package/dist/hooks/server.js +349 -0
  38. package/dist/hooks/server.js.map +1 -0
  39. package/dist/index.d.ts +3 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +6 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/otel/config.d.ts +36 -0
  44. package/dist/otel/config.d.ts.map +1 -0
  45. package/dist/otel/config.js +109 -0
  46. package/dist/otel/config.js.map +1 -0
  47. package/dist/otel/index.d.ts +4 -0
  48. package/dist/otel/index.d.ts.map +1 -0
  49. package/dist/otel/index.js +3 -0
  50. package/dist/otel/index.js.map +1 -0
  51. package/dist/otel/receiver.d.ts +10 -0
  52. package/dist/otel/receiver.d.ts.map +1 -0
  53. package/dist/otel/receiver.js +155 -0
  54. package/dist/otel/receiver.js.map +1 -0
  55. package/dist/parser/index.d.ts +4 -0
  56. package/dist/parser/index.d.ts.map +1 -0
  57. package/dist/parser/index.js +3 -0
  58. package/dist/parser/index.js.map +1 -0
  59. package/dist/parser/ingest.d.ts +20 -0
  60. package/dist/parser/ingest.d.ts.map +1 -0
  61. package/dist/parser/ingest.js +98 -0
  62. package/dist/parser/ingest.js.map +1 -0
  63. package/dist/parser/jsonl.d.ts +14 -0
  64. package/dist/parser/jsonl.d.ts.map +1 -0
  65. package/dist/parser/jsonl.js +202 -0
  66. package/dist/parser/jsonl.js.map +1 -0
  67. package/dist/parser/types.d.ts +81 -0
  68. package/dist/parser/types.d.ts.map +1 -0
  69. package/dist/parser/types.js +9 -0
  70. package/dist/parser/types.js.map +1 -0
  71. package/dist/parser/watcher.d.ts +16 -0
  72. package/dist/parser/watcher.d.ts.map +1 -0
  73. package/dist/parser/watcher.js +103 -0
  74. package/dist/parser/watcher.js.map +1 -0
  75. package/dist/pricing/index.d.ts +2 -0
  76. package/dist/pricing/index.d.ts.map +1 -0
  77. package/dist/pricing/index.js +37 -0
  78. package/dist/pricing/index.js.map +1 -0
  79. package/dist/service/index.d.ts +31 -0
  80. package/dist/service/index.d.ts.map +1 -0
  81. package/dist/service/index.js +252 -0
  82. package/dist/service/index.js.map +1 -0
  83. package/dist/storage/db.d.ts +75 -0
  84. package/dist/storage/db.d.ts.map +1 -0
  85. package/dist/storage/db.js +117 -0
  86. package/dist/storage/db.js.map +1 -0
  87. package/dist/storage/index.d.ts +4 -0
  88. package/dist/storage/index.d.ts.map +1 -0
  89. package/dist/storage/index.js +3 -0
  90. package/dist/storage/index.js.map +1 -0
  91. package/dist/storage/repo.d.ts +162 -0
  92. package/dist/storage/repo.d.ts.map +1 -0
  93. package/dist/storage/repo.js +472 -0
  94. package/dist/storage/repo.js.map +1 -0
  95. package/dist/sync/client.d.ts +24 -0
  96. package/dist/sync/client.d.ts.map +1 -0
  97. package/dist/sync/client.js +41 -0
  98. package/dist/sync/client.js.map +1 -0
  99. package/dist/sync/index.d.ts +18 -0
  100. package/dist/sync/index.d.ts.map +1 -0
  101. package/dist/sync/index.js +135 -0
  102. package/dist/sync/index.js.map +1 -0
  103. package/dist/sync/sync.test.d.ts +2 -0
  104. package/dist/sync/sync.test.d.ts.map +1 -0
  105. package/dist/sync/sync.test.js +412 -0
  106. package/dist/sync/sync.test.js.map +1 -0
  107. package/dist/sync/transform.d.ts +80 -0
  108. package/dist/sync/transform.d.ts.map +1 -0
  109. package/dist/sync/transform.js +90 -0
  110. package/dist/sync/transform.js.map +1 -0
  111. package/package.json +50 -0
  112. package/src/cli/commands.ts +332 -0
  113. package/src/cli/format.ts +133 -0
  114. package/src/context/index.ts +42 -0
  115. package/src/dashboard/html.ts +97 -0
  116. package/src/dashboard/index.html +1245 -0
  117. package/src/hooks/config.ts +119 -0
  118. package/src/hooks/git.ts +77 -0
  119. package/src/hooks/index.ts +7 -0
  120. package/src/hooks/server.ts +397 -0
  121. package/src/index.ts +6 -0
  122. package/src/otel/config.ts +141 -0
  123. package/src/otel/index.ts +8 -0
  124. package/src/otel/receiver.ts +183 -0
  125. package/src/parser/index.ts +3 -0
  126. package/src/parser/ingest.ts +119 -0
  127. package/src/parser/jsonl.ts +241 -0
  128. package/src/parser/types.ts +89 -0
  129. package/src/parser/watcher.ts +116 -0
  130. package/src/pricing/index.ts +51 -0
  131. package/src/service/index.ts +272 -0
  132. package/src/storage/db.ts +198 -0
  133. package/src/storage/index.ts +3 -0
  134. package/src/storage/repo.ts +601 -0
  135. package/src/sync/client.ts +63 -0
  136. package/src/sync/index.ts +207 -0
  137. package/src/sync/sync.test.ts +447 -0
  138. package/src/sync/transform.ts +184 -0
  139. package/tsconfig.json +19 -0
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
6
+
7
+ export interface HooksConfig {
8
+ port: number;
9
+ }
10
+
11
+ /**
12
+ * Generate the hooks configuration object for Claude Code settings.json.
13
+ */
14
+ export function generateHooksConfig(opts: HooksConfig): Record<string, unknown> {
15
+ const base = `http://localhost:${opts.port}/hook`;
16
+
17
+ return {
18
+ hooks: {
19
+ SessionStart: [
20
+ {
21
+ matcher: "",
22
+ hooks: [{ type: "http", url: `${base}/session-start` }],
23
+ },
24
+ ],
25
+ SessionEnd: [
26
+ {
27
+ matcher: "",
28
+ hooks: [{ type: "http", url: `${base}/session-end` }],
29
+ },
30
+ ],
31
+ UserPromptSubmit: [
32
+ {
33
+ hooks: [{ type: "http", url: `${base}/user-prompt` }],
34
+ },
35
+ ],
36
+ PostToolUse: [
37
+ {
38
+ matcher: "",
39
+ hooks: [{ type: "http", url: `${base}/post-tool-use` }],
40
+ },
41
+ ],
42
+ Stop: [
43
+ {
44
+ hooks: [{ type: "http", url: `${base}/stop` }],
45
+ },
46
+ ],
47
+ },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Read the current Claude settings.json, merge hooks, and write back.
53
+ */
54
+ export function installHooksToSettings(opts: HooksConfig): { path: string; merged: boolean } {
55
+ let existing: Record<string, unknown> = {};
56
+
57
+ if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
58
+ try {
59
+ existing = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
60
+ } catch {
61
+ existing = {};
62
+ }
63
+ }
64
+
65
+ const hooksConfig = generateHooksConfig(opts);
66
+ const existingHooks = (existing.hooks ?? {}) as Record<string, unknown>;
67
+ const newHooks = hooksConfig.hooks as Record<string, unknown>;
68
+
69
+ const merged = { ...existingHooks, ...newHooks };
70
+ existing.hooks = merged;
71
+
72
+ fs.mkdirSync(path.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
73
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(existing, null, 2) + "\n");
74
+
75
+ return { path: CLAUDE_SETTINGS_PATH, merged: Object.keys(existingHooks).length > 0 };
76
+ }
77
+
78
+ /**
79
+ * Remove zozul hooks from Claude settings.json.
80
+ */
81
+ export function uninstallHooksFromSettings(opts: HooksConfig): boolean {
82
+ if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) return false;
83
+
84
+ let existing: Record<string, unknown>;
85
+ try {
86
+ existing = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
87
+ } catch {
88
+ return false;
89
+ }
90
+
91
+ const hooks = existing.hooks as Record<string, unknown[]> | undefined;
92
+ if (!hooks) return false;
93
+
94
+ const base = `http://localhost:${opts.port}/hook`;
95
+ let removed = false;
96
+
97
+ for (const [event, matchers] of Object.entries(hooks)) {
98
+ if (!Array.isArray(matchers)) continue;
99
+ const filtered = matchers.filter((m: unknown) => {
100
+ const matcher = m as { hooks?: { url?: string }[] };
101
+ return !matcher.hooks?.some((h) => h.url?.startsWith(base));
102
+ });
103
+ if (filtered.length !== matchers.length) {
104
+ removed = true;
105
+ if (filtered.length === 0) {
106
+ delete hooks[event];
107
+ } else {
108
+ hooks[event] = filtered;
109
+ }
110
+ }
111
+ }
112
+
113
+ if (Object.keys(hooks).length === 0) {
114
+ delete existing.hooks;
115
+ }
116
+
117
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(existing, null, 2) + "\n");
118
+ return removed;
119
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execSync } from "node:child_process";
4
+
5
+ const HOOK_MARKER = "# zozul: auto-clear context on commit";
6
+
7
+ function getGitHookPath(hookName: string): string | null {
8
+ try {
9
+ const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf-8" }).trim();
10
+ return path.join(gitDir, "hooks", hookName);
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ const HOOK_SCRIPT = `
17
+ ${HOOK_MARKER}
18
+ if command -v zozul >/dev/null 2>&1; then
19
+ zozul context --clear 2>/dev/null
20
+ fi
21
+ `;
22
+
23
+ export function installGitHook(): { path: string; created: boolean } | null {
24
+ const hookPath = getGitHookPath("post-commit");
25
+ if (!hookPath) return null;
26
+
27
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
28
+
29
+ if (fs.existsSync(hookPath)) {
30
+ const existing = fs.readFileSync(hookPath, "utf-8");
31
+ if (existing.includes(HOOK_MARKER)) {
32
+ return { path: hookPath, created: false };
33
+ }
34
+ // Append to existing hook
35
+ fs.appendFileSync(hookPath, "\n" + HOOK_SCRIPT);
36
+ } else {
37
+ fs.writeFileSync(hookPath, "#!/bin/sh\n" + HOOK_SCRIPT);
38
+ }
39
+
40
+ fs.chmodSync(hookPath, 0o755);
41
+ return { path: hookPath, created: true };
42
+ }
43
+
44
+ export function uninstallGitHook(): boolean {
45
+ const hookPath = getGitHookPath("post-commit");
46
+ if (!hookPath || !fs.existsSync(hookPath)) return false;
47
+
48
+ const content = fs.readFileSync(hookPath, "utf-8");
49
+ if (!content.includes(HOOK_MARKER)) return false;
50
+
51
+ // Remove the zozul block
52
+ const lines = content.split("\n");
53
+ const filtered: string[] = [];
54
+ let inBlock = false;
55
+ for (const line of lines) {
56
+ if (line.includes(HOOK_MARKER)) {
57
+ inBlock = true;
58
+ continue;
59
+ }
60
+ if (inBlock && line.trim() === "fi") {
61
+ inBlock = false;
62
+ continue;
63
+ }
64
+ if (inBlock) continue;
65
+ filtered.push(line);
66
+ }
67
+
68
+ const remaining = filtered.join("\n").trim();
69
+ if (remaining === "#!/bin/sh" || remaining === "") {
70
+ fs.unlinkSync(hookPath);
71
+ } else {
72
+ fs.writeFileSync(hookPath, remaining + "\n");
73
+ fs.chmodSync(hookPath, 0o755);
74
+ }
75
+
76
+ return true;
77
+ }
@@ -0,0 +1,7 @@
1
+ export { createHookServer } from "./server.js";
2
+ export type { HookServerOptions } from "./server.js";
3
+ export {
4
+ generateHooksConfig,
5
+ installHooksToSettings,
6
+ uninstallHooksFromSettings,
7
+ } from "./config.js";
@@ -0,0 +1,397 @@
1
+ import http from "node:http";
2
+ import type { SessionRepo } from "../storage/repo.js";
3
+ import { ingestSessionFile } from "../parser/ingest.js";
4
+ import { handleOtlpMetrics, handleOtlpLogs } from "../otel/receiver.js";
5
+ import { dashboardHtml, dashboardHtmlWithToggle } from "../dashboard/html.js";
6
+ import { getActiveContext, clearActiveContext } from "../context/index.js";
7
+
8
+ export interface HookServerOptions {
9
+ port: number;
10
+ repo: SessionRepo;
11
+ verbose?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Unified HTTP server that handles:
16
+ * - Hook events from Claude Code (POST /hook/*)
17
+ * - OTLP metrics and logs (POST /v1/metrics, POST /v1/logs)
18
+ * - Dashboard API (GET /api/*)
19
+ * - Web dashboard (GET /dashboard)
20
+ */
21
+ export function createHookServer(opts: HookServerOptions): http.Server {
22
+ const { repo, verbose } = opts;
23
+ // Track last SessionEnd time per session to suppress rapid duplicates (Claude Code
24
+ // sometimes fires two SessionEnd events within seconds for the same session).
25
+ const lastSessionEnd = new Map<string, number>();
26
+
27
+ const server = http.createServer(async (req, res) => {
28
+ const url = req.url ?? "/";
29
+ const method = req.method ?? "GET";
30
+
31
+ try {
32
+ // ── OTLP receiver ──
33
+ if (method === "POST" && url === "/v1/metrics") {
34
+ const body = await readBody(req);
35
+ const count = handleOtlpMetrics(body, repo, verbose);
36
+ if (verbose) log(`otel metrics: ${count} data points`);
37
+ sendJson(res, 200, {});
38
+ return;
39
+ }
40
+
41
+ if (method === "POST" && url === "/v1/logs") {
42
+ const body = await readBody(req);
43
+ const count = handleOtlpLogs(body, repo, verbose);
44
+ if (verbose) log(`otel logs: ${count} events`);
45
+ sendJson(res, 200, {});
46
+ return;
47
+ }
48
+
49
+ // ── Hook events ──
50
+ if (method === "POST" && url.startsWith("/hook")) {
51
+ await handleHookEvent(url, req, repo, res, verbose, lastSessionEnd);
52
+ return;
53
+ }
54
+
55
+ // ── Dashboard ──
56
+ if (method === "GET" && (url === "/dashboard" || url === "/")) {
57
+ const apiUrl = process.env.ZOZUL_API_URL;
58
+ const apiKey = process.env.ZOZUL_API_KEY;
59
+ const html = apiUrl && apiKey
60
+ ? dashboardHtmlWithToggle({ apiUrl, apiKey }, "local")
61
+ : dashboardHtml();
62
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
63
+ res.end(html);
64
+ return;
65
+ }
66
+
67
+ // ── API routes ──
68
+ if (method === "GET" && url.startsWith("/api/")) {
69
+ handleApiRoute(url, repo, res);
70
+ return;
71
+ }
72
+
73
+ sendJson(res, 404, { error: "Not found" });
74
+ } catch (err) {
75
+ if (verbose) process.stderr.write(` error: ${err}\n`);
76
+ sendJson(res, 500, { error: "Internal server error" });
77
+ }
78
+ });
79
+
80
+ return server;
81
+ }
82
+
83
+ // ── Hook handler ──
84
+
85
+ const SESSION_END_DEDUP_MS = 60_000;
86
+
87
+ async function handleHookEvent(
88
+ url: string,
89
+ req: http.IncomingMessage,
90
+ repo: SessionRepo,
91
+ res: http.ServerResponse,
92
+ verbose?: boolean,
93
+ lastSessionEnd?: Map<string, number>,
94
+ ): Promise<void> {
95
+ const body = await readBody(req);
96
+
97
+ try {
98
+ const payload = JSON.parse(body);
99
+ const eventName = routeToEventName(url);
100
+
101
+ if (verbose) log(`hook: ${eventName} session=${payload.session_id ?? "?"}`);
102
+
103
+ // Suppress duplicate SessionEnd events for the same session within the dedup window
104
+ if (eventName === "SessionEnd" && payload.session_id && lastSessionEnd) {
105
+ const last = lastSessionEnd.get(payload.session_id) ?? 0;
106
+ const now = Date.now();
107
+ if (now - last < SESSION_END_DEDUP_MS) {
108
+ if (verbose) log(` -> suppressed duplicate SessionEnd for ${payload.session_id}`);
109
+ sendJson(res, 200, { ok: true, deduplicated: true });
110
+ return;
111
+ }
112
+ lastSessionEnd.set(payload.session_id, now);
113
+ }
114
+
115
+ repo.insertHookEvent({
116
+ session_id: payload.session_id ?? null,
117
+ event_name: eventName,
118
+ timestamp: new Date().toISOString(),
119
+ payload: body,
120
+ });
121
+
122
+ if (eventName === "SessionEnd" && payload.transcript_path) {
123
+ try {
124
+ const projectPath = decodeProjectPathFromTranscript(payload.transcript_path);
125
+ await ingestSessionFile(repo, payload.transcript_path, projectPath ?? undefined);
126
+ if (verbose) log(` -> ingested transcript: ${payload.transcript_path}${projectPath ? ` (project: ${projectPath})` : ""}`);
127
+ } catch (err) {
128
+ if (verbose) log(` -> transcript ingest failed: ${err}`);
129
+ }
130
+ }
131
+
132
+ // Auto-clear context when Claude runs git commit or git push
133
+ if (eventName === "PostToolUse" && payload.tool_name === "Bash") {
134
+ const cmd = payload.tool_input?.command ?? "";
135
+ if (/\bgit\s+(commit|push)\b/.test(cmd)) {
136
+ const ctx = getActiveContext();
137
+ if (ctx?.active && ctx.active.length > 0) {
138
+ clearActiveContext();
139
+ if (verbose) log(` -> cleared task context after: ${cmd.slice(0, 60)}`);
140
+ }
141
+ }
142
+ }
143
+
144
+ sendJson(res, 200, { ok: true });
145
+ } catch {
146
+ sendJson(res, 400, { error: "Invalid JSON" });
147
+ }
148
+ }
149
+
150
+ // ── API handler ──
151
+
152
+ function handleApiRoute(url: string, repo: SessionRepo, res: http.ServerResponse): void {
153
+ const path = url.replace(/\?.*$/, "");
154
+
155
+ if (path === "/api/stats") {
156
+ const stats = repo.getAggregateStats();
157
+ sendJson(res, 200, stats ?? {});
158
+ return;
159
+ }
160
+
161
+ if (path === "/api/sessions") {
162
+ const qs = new URL(url, "http://x").searchParams;
163
+ const limit = Math.min(500, Math.max(1, parseInt(qs.get("limit") ?? "50", 10)));
164
+ const offset = Math.max(0, parseInt(qs.get("offset") ?? "0", 10));
165
+ const sessions = repo.listSessions(limit, offset);
166
+ const total = repo.countSessions();
167
+ sendJson(res, 200, { sessions, total, limit, offset });
168
+ return;
169
+ }
170
+
171
+ const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)$/);
172
+ if (sessionMatch) {
173
+ const session = repo.getSession(sessionMatch[1]);
174
+ if (!session) { sendJson(res, 404, { error: "Session not found" }); return; }
175
+ sendJson(res, 200, session);
176
+ return;
177
+ }
178
+
179
+ const turnsMatch = path.match(/^\/api\/sessions\/([^/]+)\/turns$/);
180
+ if (turnsMatch) {
181
+ const turns = repo.getSessionTurns(turnsMatch[1]);
182
+ sendJson(res, 200, turns);
183
+ return;
184
+ }
185
+
186
+ if (path === "/api/metrics/tokens") {
187
+ const { from, to, stepSeconds } = parseTimeRange(url);
188
+ sendJson(res, 200, repo.getTokenTimeSeries(from, to, stepSeconds));
189
+ return;
190
+ }
191
+
192
+ if (path === "/api/metrics/cost") {
193
+ const { from, to, stepSeconds } = parseTimeRange(url);
194
+ sendJson(res, 200, repo.getCostTimeSeries(from, to, stepSeconds));
195
+ return;
196
+ }
197
+
198
+ if (path === "/api/metrics/tools") {
199
+ sendJson(res, 200, repo.getToolUsageBreakdown());
200
+ return;
201
+ }
202
+
203
+ if (path === "/api/metrics/models") {
204
+ sendJson(res, 200, repo.getModelBreakdown());
205
+ return;
206
+ }
207
+
208
+ if (path === "/api/context") {
209
+ const ctx = getActiveContext();
210
+ sendJson(res, 200, ctx ?? { active: null });
211
+ return;
212
+ }
213
+
214
+ if (path === "/api/tasks") {
215
+ const tasks = repo.listTasks();
216
+ sendJson(res, 200, tasks);
217
+ return;
218
+ }
219
+
220
+ if (path === "/api/tasks/stats") {
221
+ const qs = new URL(url, "http://x").searchParams;
222
+ const tagsParam = qs.get("tags") ?? "";
223
+ const tags = tagsParam.split(",").map(t => t.trim()).filter(Boolean);
224
+ if (tags.length === 0) {
225
+ sendJson(res, 400, { error: "tags parameter required" });
226
+ return;
227
+ }
228
+ const mode = qs.get("mode") === "any" ? "any" as const : "all" as const;
229
+ const from = qs.get("from") ?? undefined;
230
+ const to = qs.get("to") ?? undefined;
231
+ const stats = repo.getStatsByTasks(tags, mode, from, to);
232
+ sendJson(res, 200, stats ?? {});
233
+ return;
234
+ }
235
+
236
+ const turnBlockMatch = path.match(/^\/api\/turns\/(\d+)\/block$/);
237
+ if (turnBlockMatch) {
238
+ const turnId = parseInt(turnBlockMatch[1], 10);
239
+ const block = repo.getTurnBlock(turnId);
240
+ sendJson(res, 200, block);
241
+ return;
242
+ }
243
+
244
+ if (path === "/api/tasks/turns") {
245
+ const qs = new URL(url, "http://x").searchParams;
246
+ const tagsParam = qs.get("tags") ?? "";
247
+ const tags = tagsParam ? tagsParam.split(",").map(t => t.trim()).filter(Boolean) : undefined;
248
+ const mode = qs.get("mode") === "all" ? "all" as const : "any" as const;
249
+ const from = qs.get("from") ?? undefined;
250
+ const to = qs.get("to") ?? undefined;
251
+ const limit = Math.min(200, Math.max(1, parseInt(qs.get("limit") ?? "50", 10)));
252
+ const offset = Math.max(0, parseInt(qs.get("offset") ?? "0", 10));
253
+ const turns = repo.getTaggedTurns({ tags, mode, from, to, limit, offset });
254
+ sendJson(res, 200, turns);
255
+ return;
256
+ }
257
+
258
+ const taskTurnsMatch = path.match(/^\/api\/tasks\/([^/]+)\/turns$/);
259
+ if (taskTurnsMatch) {
260
+ const taskName = decodeURIComponent(taskTurnsMatch[1]);
261
+ const qs = new URL(url, "http://x").searchParams;
262
+ const limit = Math.min(500, Math.max(1, parseInt(qs.get("limit") ?? "50", 10)));
263
+ const offset = Math.max(0, parseInt(qs.get("offset") ?? "0", 10));
264
+ const turns = repo.getTurnsByTask(taskName, limit, offset);
265
+ sendJson(res, 200, turns);
266
+ return;
267
+ }
268
+
269
+ const taskStatsMatch = path.match(/^\/api\/tasks\/([^/]+)\/stats$/);
270
+ if (taskStatsMatch) {
271
+ const taskName = decodeURIComponent(taskStatsMatch[1]);
272
+ const stats = repo.getStatsByTask(taskName);
273
+ sendJson(res, 200, stats ?? {});
274
+ return;
275
+ }
276
+
277
+ sendJson(res, 404, { error: "Unknown API route" });
278
+ }
279
+
280
+ // ── Helpers ──
281
+
282
+ function routeToEventName(url: string): string {
283
+ const segment = url.replace(/^\/hooks?\/?/, "").replace(/\/$/, "");
284
+ if (!segment) return "unknown";
285
+
286
+ const mapped: Record<string, string> = {
287
+ "session-start": "SessionStart",
288
+ "session-end": "SessionEnd",
289
+ "post-tool-use": "PostToolUse",
290
+ "pre-tool-use": "PreToolUse",
291
+ "stop": "Stop",
292
+ "user-prompt": "UserPromptSubmit",
293
+ "notification": "Notification",
294
+ };
295
+ return mapped[segment] ?? segment;
296
+ }
297
+
298
+ interface TimeRange {
299
+ from: string;
300
+ to: string;
301
+ stepSeconds: number;
302
+ }
303
+
304
+ function autoStep(rangeMs: number): number {
305
+ const hours = rangeMs / 3_600_000;
306
+ if (hours <= 1) return 60;
307
+ if (hours <= 6) return 300;
308
+ if (hours <= 24) return 900;
309
+ if (hours <= 168) return 3600;
310
+ return 86400;
311
+ }
312
+
313
+ function parseStepParam(step: string): number {
314
+ const m = step.match(/^(\d+)(m|h|d)$/);
315
+ if (!m) return 3600;
316
+ const n = parseInt(m[1], 10);
317
+ if (m[2] === "m") return n * 60;
318
+ if (m[2] === "h") return n * 3600;
319
+ return n * 86400;
320
+ }
321
+
322
+ /**
323
+ * Parse time-range query params.
324
+ * Supports presets (?range=7d) and custom (?from=ISO&to=ISO&step=5m).
325
+ */
326
+ function parseTimeRange(url: string): TimeRange {
327
+ const qs = new URL(url, "http://x").searchParams;
328
+
329
+ if (qs.has("from") && qs.has("to")) {
330
+ const from = qs.get("from")!;
331
+ const to = qs.get("to")!;
332
+ const rangeMs = new Date(to).getTime() - new Date(from).getTime();
333
+ const stepSeconds = qs.has("step") && qs.get("step") !== "auto"
334
+ ? parseStepParam(qs.get("step")!)
335
+ : autoStep(rangeMs);
336
+ return { from, to, stepSeconds };
337
+ }
338
+
339
+ const rangeMatch = url.match(/[?&]range=(\d+)(h|d)/);
340
+ const now = new Date();
341
+ if (!rangeMatch) {
342
+ const from = new Date(now.getTime() - 30 * 24 * 3_600_000).toISOString();
343
+ return { from, to: now.toISOString(), stepSeconds: 86400 };
344
+ }
345
+
346
+ const n = parseInt(rangeMatch[1], 10);
347
+ const unit = rangeMatch[2];
348
+ const ms = unit === "h" ? n * 3_600_000 : n * 24 * 3_600_000;
349
+ const from = new Date(now.getTime() - ms).toISOString();
350
+ return { from, to: now.toISOString(), stepSeconds: autoStep(ms) };
351
+ }
352
+
353
+ const MAX_BODY_BYTES = 50 * 1024 * 1024; // 50 MB
354
+
355
+ function readBody(req: http.IncomingMessage): Promise<string> {
356
+ return new Promise((resolve, reject) => {
357
+ const chunks: Buffer[] = [];
358
+ let total = 0;
359
+
360
+ req.on("data", (chunk: Buffer) => {
361
+ total += chunk.byteLength;
362
+ if (total > MAX_BODY_BYTES) {
363
+ req.destroy();
364
+ reject(new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`));
365
+ return;
366
+ }
367
+ chunks.push(chunk);
368
+ });
369
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
370
+ req.on("error", reject);
371
+ });
372
+ }
373
+
374
+ function sendJson(res: http.ServerResponse, status: number, data: unknown): void {
375
+ res.writeHead(status, {
376
+ "Content-Type": "application/json",
377
+ "Access-Control-Allow-Origin": "*",
378
+ });
379
+ res.end(JSON.stringify(data));
380
+ }
381
+
382
+ function log(msg: string): void {
383
+ process.stderr.write(`[${new Date().toISOString()}] ${msg}\n`);
384
+ }
385
+
386
+ /**
387
+ * Extract the project path from a transcript file path.
388
+ * Claude Code stores transcripts at:
389
+ * ~/.claude/projects/<encoded-path>/<uuid>.jsonl
390
+ * where <encoded-path> is the absolute path with "/" replaced by "-".
391
+ * Returns null if the path doesn't match the expected structure.
392
+ */
393
+ function decodeProjectPathFromTranscript(transcriptPath: string): string | null {
394
+ const match = transcriptPath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\.jsonl$/i);
395
+ if (!match) return null;
396
+ return match[1].replace(/-/g, "/");
397
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { buildCli } from "./cli/commands.js";
4
+
5
+ const program = buildCli();
6
+ program.parseAsync(process.argv);