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,89 @@
1
+ /**
2
+ * Types representing the JSONL session file format that Claude Code writes to:
3
+ * ~/.claude/projects/<encoded-path>/sessions/<session-uuid>.jsonl
4
+ *
5
+ * Each line is one JSON object. The first line is typically a summary.
6
+ * Subsequent lines are user/assistant messages with full content.
7
+ */
8
+
9
+ export interface ContentBlock {
10
+ type: string;
11
+ text?: string;
12
+ id?: string;
13
+ name?: string;
14
+ input?: Record<string, unknown>;
15
+ tool_use_id?: string;
16
+ content?: string | ContentBlock[];
17
+ }
18
+
19
+ export interface UsageInfo {
20
+ input_tokens?: number;
21
+ output_tokens?: number;
22
+ cache_creation_input_tokens?: number;
23
+ cache_read_input_tokens?: number;
24
+ }
25
+
26
+ export interface MessagePayload {
27
+ role: string;
28
+ content: string | ContentBlock[];
29
+ model?: string;
30
+ usage?: UsageInfo;
31
+ }
32
+
33
+ export interface SessionEntry {
34
+ type: string;
35
+ uuid?: string;
36
+ parentUuid?: string;
37
+ timestamp?: string;
38
+ sessionId?: string;
39
+ cwd?: string;
40
+ version?: string;
41
+ message?: MessagePayload;
42
+ requestId?: string;
43
+ costUSD?: number;
44
+ durationMs?: number;
45
+ isSidechain?: boolean;
46
+ sourceToolAssistantUUID?: string;
47
+
48
+ // Summary-specific fields
49
+ summary?: string;
50
+ leafUuid?: string;
51
+ numLeaves?: number;
52
+ }
53
+
54
+ export interface ParsedSession {
55
+ sessionId: string;
56
+ projectPath: string | null;
57
+ startedAt: string;
58
+ endedAt: string | null;
59
+ model: string | null;
60
+ turns: ParsedTurn[];
61
+ totalInputTokens: number;
62
+ totalOutputTokens: number;
63
+ totalCacheReadTokens: number;
64
+ totalCacheCreationTokens: number;
65
+ totalCostUsd: number;
66
+ totalDurationMs: number;
67
+ }
68
+
69
+ export interface ParsedTurn {
70
+ turnIndex: number;
71
+ role: string;
72
+ timestamp: string;
73
+ inputTokens: number;
74
+ outputTokens: number;
75
+ cacheReadTokens: number;
76
+ cacheCreationTokens: number;
77
+ costUsd: number;
78
+ durationMs: number;
79
+ model: string | null;
80
+ contentText: string;
81
+ toolCalls: ToolCallInfo[];
82
+ isRealUser: boolean;
83
+ }
84
+
85
+ export interface ToolCallInfo {
86
+ toolName: string;
87
+ toolInput: Record<string, unknown>;
88
+ toolResult?: string;
89
+ }
@@ -0,0 +1,116 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import type { SessionRepo } from "../storage/repo.js";
5
+ import { ingestSessionFile } from "./ingest.js";
6
+ import { discoverSessionFiles } from "./jsonl.js";
7
+
8
+ const PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
9
+ const DEBOUNCE_MS = 500;
10
+
11
+ export interface WatcherOptions {
12
+ repo: SessionRepo;
13
+ verbose?: boolean;
14
+ /** Re-ingest all existing JSONL files on startup to catch up on missed sessions. Default true. */
15
+ catchUp?: boolean;
16
+ }
17
+
18
+ /**
19
+ * Watch ~/.claude/projects for JSONL session file changes and ingest them
20
+ * into the database as they are written. Returns a stop function.
21
+ *
22
+ * On startup, performs an initial catch-up pass so that sessions written
23
+ * while zozul was not running are immediately available.
24
+ */
25
+ export async function watchSessionFiles(opts: WatcherOptions): Promise<() => void> {
26
+ const { repo, verbose } = opts;
27
+ const catchUp = opts.catchUp ?? true;
28
+
29
+ // ── Initial catch-up pass ──
30
+ if (catchUp) {
31
+ const files = discoverSessionFiles();
32
+ let caught = 0;
33
+ for (const { filePath, projectPath } of files) {
34
+ try {
35
+ await ingestSessionFile(repo, filePath, projectPath);
36
+ caught++;
37
+ } catch {
38
+ // Ignore parse errors on individual files
39
+ }
40
+ }
41
+ if (verbose && caught > 0) {
42
+ process.stderr.write(`[watcher] catch-up: ingested ${caught} session file(s)\n`);
43
+ }
44
+ }
45
+
46
+ if (!fs.existsSync(PROJECTS_DIR)) {
47
+ if (verbose) {
48
+ process.stderr.write(`[watcher] ${PROJECTS_DIR} not found, watching skipped\n`);
49
+ }
50
+ return () => {};
51
+ }
52
+
53
+ // ── Per-file debounce ──
54
+ const timers = new Map<string, NodeJS.Timeout>();
55
+
56
+ function scheduleIngest(filePath: string) {
57
+ const existing = timers.get(filePath);
58
+ if (existing) clearTimeout(existing);
59
+
60
+ timers.set(filePath, setTimeout(async () => {
61
+ timers.delete(filePath);
62
+ try {
63
+ const projectPath = decodeProjectPath(filePath);
64
+ await ingestSessionFile(repo, filePath, projectPath ?? undefined);
65
+ if (verbose) {
66
+ process.stderr.write(`[watcher] ingested: ${filePath}\n`);
67
+ }
68
+ } catch (err) {
69
+ if (verbose) {
70
+ process.stderr.write(`[watcher] ingest failed (${filePath}): ${err}\n`);
71
+ }
72
+ }
73
+ }, DEBOUNCE_MS));
74
+ }
75
+
76
+ // ── fs.watch with recursive ──
77
+ // recursive: true uses FSEvents on macOS and ReadDirectoryChangesW on Windows.
78
+ const watcher = fs.watch(PROJECTS_DIR, { recursive: true }, (_event, filename) => {
79
+ if (!filename) return;
80
+ if (!filename.endsWith(".jsonl")) return;
81
+
82
+ // filename is relative to PROJECTS_DIR on macOS/Windows
83
+ const filePath = path.join(PROJECTS_DIR, filename);
84
+
85
+ // Only act if the file actually exists (ignore delete events)
86
+ if (!fs.existsSync(filePath)) return;
87
+
88
+ scheduleIngest(filePath);
89
+ });
90
+
91
+ watcher.on("error", (err) => {
92
+ if (verbose) process.stderr.write(`[watcher] error: ${err}\n`);
93
+ });
94
+
95
+ if (verbose) {
96
+ process.stderr.write(`[watcher] watching ${PROJECTS_DIR}\n`);
97
+ }
98
+
99
+ return () => {
100
+ watcher.close();
101
+ for (const t of timers.values()) clearTimeout(t);
102
+ timers.clear();
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Extract the decoded project path from an absolute JSONL file path.
108
+ * ~/.claude/projects/<encoded>/<uuid>.jsonl
109
+ * where <encoded> has "/" replaced with "-".
110
+ */
111
+ function decodeProjectPath(filePath: string): string | null {
112
+ // Match project dir directly containing the UUID file
113
+ const match = filePath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\.jsonl$/i);
114
+ if (!match) return null;
115
+ return match[1].replace(/-/g, "/");
116
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Claude model pricing per million tokens.
3
+ * Cache creation uses 5-minute TTL pricing (the default for Claude Code).
4
+ */
5
+ interface ModelPricing {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheCreation: number;
10
+ }
11
+
12
+ // Prices per million tokens
13
+ const PRICING: Record<string, ModelPricing> = {
14
+ "claude-opus-4-6": { input: 5.00, output: 25.00, cacheRead: 0.50, cacheCreation: 6.25 },
15
+ "claude-sonnet-4-6": { input: 3.00, output: 15.00, cacheRead: 0.30, cacheCreation: 3.75 },
16
+ "claude-sonnet-4-5-20250514":{ input: 3.00, output: 15.00, cacheRead: 0.30, cacheCreation: 3.75 },
17
+ "claude-haiku-4-5-20251001": { input: 1.00, output: 5.00, cacheRead: 0.10, cacheCreation: 1.25 },
18
+ "claude-3-5-haiku-20241022": { input: 0.80, output: 4.00, cacheRead: 0.08, cacheCreation: 1.00 },
19
+ };
20
+
21
+ function findPricing(model: string): ModelPricing | null {
22
+ if (PRICING[model]) return PRICING[model];
23
+ // Fuzzy match: try prefix matching for versioned model names
24
+ for (const [key, pricing] of Object.entries(PRICING)) {
25
+ if (model.startsWith(key) || key.startsWith(model)) return pricing;
26
+ }
27
+ // Match by family name
28
+ if (model.includes("opus")) return PRICING["claude-opus-4-6"];
29
+ if (model.includes("sonnet")) return PRICING["claude-sonnet-4-6"];
30
+ if (model.includes("haiku")) return PRICING["claude-haiku-4-5-20251001"];
31
+ return null;
32
+ }
33
+
34
+ export function computeTurnCost(
35
+ model: string | null,
36
+ inputTokens: number,
37
+ outputTokens: number,
38
+ cacheReadTokens: number,
39
+ cacheCreationTokens: number,
40
+ ): number {
41
+ if (!model) return 0;
42
+ const pricing = findPricing(model);
43
+ if (!pricing) return 0;
44
+
45
+ return (
46
+ (inputTokens * pricing.input) / 1_000_000 +
47
+ (outputTokens * pricing.output) / 1_000_000 +
48
+ (cacheReadTokens * pricing.cacheRead) / 1_000_000 +
49
+ (cacheCreationTokens * pricing.cacheCreation) / 1_000_000
50
+ );
51
+ }
@@ -0,0 +1,272 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { execSync } from "node:child_process";
5
+
6
+ const LABEL = "com.zozul.serve";
7
+ const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
8
+ const SYSTEMD_DIR = path.join(os.homedir(), ".config", "systemd", "user");
9
+ const SYSTEMD_PATH = path.join(SYSTEMD_DIR, "zozul.service");
10
+ const LOG_PATH = path.join(os.homedir(), ".zozul", "zozul.log");
11
+
12
+ export interface ServiceInstallOptions {
13
+ port: number;
14
+ dbPath?: string;
15
+ }
16
+
17
+ export interface ServiceResult {
18
+ platform: "macos" | "linux" | "unsupported";
19
+ servicePath: string;
20
+ alreadyRunning: boolean;
21
+ }
22
+
23
+ /**
24
+ * Install and immediately start zozul as a background service.
25
+ * Uses launchd on macOS, systemd --user on Linux.
26
+ */
27
+ export function installService(opts: ServiceInstallOptions): ServiceResult {
28
+ const platform = detectPlatform();
29
+ if (platform === "unsupported") {
30
+ throw new Error(`Service install is not supported on ${process.platform}. Run 'zozul serve' manually.`);
31
+ }
32
+
33
+ // Build env vars to bake into the service so it doesn't depend on .env
34
+ const env: Record<string, string> = {
35
+ ZOZUL_PORT: String(opts.port),
36
+ };
37
+ if (opts.dbPath) env.ZOZUL_DB_PATH = opts.dbPath;
38
+ if (process.env.ZOZUL_API_URL) env.ZOZUL_API_URL = process.env.ZOZUL_API_URL;
39
+ if (process.env.ZOZUL_API_KEY) env.ZOZUL_API_KEY = process.env.ZOZUL_API_KEY;
40
+ if (process.env.ZOZUL_VERBOSE) env.ZOZUL_VERBOSE = process.env.ZOZUL_VERBOSE;
41
+
42
+ if (platform === "macos") {
43
+ return installLaunchd(env);
44
+ } else {
45
+ return installSystemd(env);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Stop and remove the zozul background service.
51
+ */
52
+ export function uninstallService(): { removed: boolean; platform: "macos" | "linux" | "unsupported" } {
53
+ const platform = detectPlatform();
54
+
55
+ if (platform === "macos") {
56
+ let removed = false;
57
+ if (fs.existsSync(PLIST_PATH)) {
58
+ try {
59
+ const uid = os.userInfo().uid;
60
+ execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
61
+ } catch {
62
+ // Not loaded — that's fine
63
+ }
64
+ fs.unlinkSync(PLIST_PATH);
65
+ removed = true;
66
+ }
67
+ return { removed, platform };
68
+ }
69
+
70
+ if (platform === "linux") {
71
+ let removed = false;
72
+ if (fs.existsSync(SYSTEMD_PATH)) {
73
+ try {
74
+ execSync("systemctl --user disable --now zozul", { stdio: "ignore" });
75
+ } catch {
76
+ // Not enabled — that's fine
77
+ }
78
+ fs.unlinkSync(SYSTEMD_PATH);
79
+ try {
80
+ execSync("systemctl --user daemon-reload", { stdio: "ignore" });
81
+ } catch { /* ignore */ }
82
+ removed = true;
83
+ }
84
+ return { removed, platform };
85
+ }
86
+
87
+ return { removed: false, platform: "unsupported" };
88
+ }
89
+
90
+ /**
91
+ * Restart the running service in-place (kills and relaunches the current process).
92
+ * Throws if the service is not installed.
93
+ */
94
+ export function restartService(): void {
95
+ const platform = detectPlatform();
96
+
97
+ if (platform === "macos") {
98
+ if (!fs.existsSync(PLIST_PATH)) throw new Error("Service is not installed. Run 'zozul install --service' first.");
99
+ const uid = os.userInfo().uid;
100
+ execSync(`launchctl kickstart -k gui/${uid}/${LABEL}`, { stdio: "ignore" });
101
+ return;
102
+ }
103
+
104
+ if (platform === "linux") {
105
+ if (!fs.existsSync(SYSTEMD_PATH)) throw new Error("Service is not installed. Run 'zozul install --service' first.");
106
+ execSync("systemctl --user restart zozul", { stdio: "ignore" });
107
+ return;
108
+ }
109
+
110
+ throw new Error("Service restart is not supported on this platform.");
111
+ }
112
+
113
+ /**
114
+ * Returns a human-readable status string for the running service.
115
+ */
116
+ export function serviceStatus(): string {
117
+ const platform = detectPlatform();
118
+
119
+ if (platform === "macos") {
120
+ if (!fs.existsSync(PLIST_PATH)) return "not installed";
121
+ try {
122
+ const uid = os.userInfo().uid;
123
+ const out = execSync(`launchctl print gui/${uid}/${LABEL} 2>&1`, { encoding: "utf-8" });
124
+ const stateMatch = out.match(/state = (.+)/);
125
+ const state = stateMatch?.[1]?.trim() ?? "unknown";
126
+ const pidMatch = out.match(/pid = (\d+)/);
127
+ if (pidMatch) return `running (pid ${pidMatch[1]})`;
128
+ if (state === "running") return "running";
129
+ if (state === "spawn scheduled") return "installed (starting…)";
130
+ return `installed (${state})`;
131
+ } catch {
132
+ return "installed (not running)";
133
+ }
134
+ }
135
+
136
+ if (platform === "linux") {
137
+ if (!fs.existsSync(SYSTEMD_PATH)) return "not installed";
138
+ try {
139
+ execSync("systemctl --user is-active zozul", { stdio: "ignore" });
140
+ return "running";
141
+ } catch {
142
+ return "installed (not running)";
143
+ }
144
+ }
145
+
146
+ return "unsupported platform";
147
+ }
148
+
149
+ // ── macOS launchd ──
150
+
151
+ function installLaunchd(env: Record<string, string>): ServiceResult {
152
+ const nodeBin = process.execPath;
153
+ const scriptPath = path.resolve(process.argv[1]);
154
+
155
+ fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true });
156
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
157
+
158
+ const envEntries = Object.entries(env)
159
+ .map(([k, v]) => `\t\t<key>${k}</key>\n\t\t<string>${v}</string>`)
160
+ .join("\n");
161
+
162
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
163
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
164
+ <plist version="1.0">
165
+ <dict>
166
+ \t<key>Label</key>
167
+ \t<string>${LABEL}</string>
168
+ \t<key>ProgramArguments</key>
169
+ \t<array>
170
+ \t\t<string>${nodeBin}</string>
171
+ \t\t<string>${scriptPath}</string>
172
+ \t\t<string>serve</string>
173
+ \t</array>
174
+ \t<key>EnvironmentVariables</key>
175
+ \t<dict>
176
+ ${envEntries}
177
+ \t</dict>
178
+ \t<key>RunAtLoad</key>
179
+ \t<true/>
180
+ \t<key>KeepAlive</key>
181
+ \t<dict>
182
+ \t\t<key>SuccessfulExit</key>
183
+ \t\t<false/>
184
+ \t</dict>
185
+ \t<key>StandardOutPath</key>
186
+ \t<string>${LOG_PATH}</string>
187
+ \t<key>StandardErrorPath</key>
188
+ \t<string>${LOG_PATH}</string>
189
+ \t<key>WorkingDirectory</key>
190
+ \t<string>${path.dirname(LOG_PATH)}</string>
191
+ </dict>
192
+ </plist>
193
+ `;
194
+
195
+ fs.writeFileSync(PLIST_PATH, plist, "utf-8");
196
+
197
+ // Unload any previous version first, then bootstrap
198
+ let alreadyRunning = false;
199
+ const uid = os.userInfo().uid;
200
+ try {
201
+ execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
202
+ } catch {
203
+ // Wasn't loaded — fine
204
+ }
205
+ try {
206
+ execSync(`launchctl bootstrap gui/${uid} "${PLIST_PATH}"`, { stdio: "pipe" });
207
+ } catch (err: unknown) {
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ if (msg.includes("already")) {
210
+ alreadyRunning = true;
211
+ } else {
212
+ throw new Error(`launchctl bootstrap failed: ${msg}`);
213
+ }
214
+ }
215
+
216
+ return { platform: "macos", servicePath: PLIST_PATH, alreadyRunning };
217
+ }
218
+
219
+ // ── Linux systemd ──
220
+
221
+ function installSystemd(env: Record<string, string>): ServiceResult {
222
+ const nodeBin = process.execPath;
223
+ const scriptPath = path.resolve(process.argv[1]);
224
+
225
+ fs.mkdirSync(SYSTEMD_DIR, { recursive: true });
226
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
227
+
228
+ const envLines = Object.entries(env)
229
+ .map(([k, v]) => `Environment="${k}=${v}"`)
230
+ .join("\n");
231
+
232
+ const unit = `[Unit]
233
+ Description=zozul — Agent Observability
234
+ After=network.target
235
+
236
+ [Service]
237
+ ExecStart=${nodeBin} ${scriptPath} serve
238
+ ${envLines}
239
+ WorkingDirectory=${path.dirname(LOG_PATH)}
240
+ Restart=on-failure
241
+ RestartSec=5
242
+ StandardOutput=append:${LOG_PATH}
243
+ StandardError=append:${LOG_PATH}
244
+
245
+ [Install]
246
+ WantedBy=default.target
247
+ `;
248
+
249
+ fs.writeFileSync(SYSTEMD_PATH, unit, "utf-8");
250
+
251
+ execSync("systemctl --user daemon-reload", { stdio: "ignore" });
252
+
253
+ let alreadyRunning = false;
254
+ try {
255
+ execSync("systemctl --user enable --now zozul", { stdio: "pipe" });
256
+ } catch (err: unknown) {
257
+ const msg = err instanceof Error ? err.message : String(err);
258
+ if (msg.includes("already")) {
259
+ alreadyRunning = true;
260
+ } else {
261
+ throw new Error(`systemctl enable failed: ${msg}`);
262
+ }
263
+ }
264
+
265
+ return { platform: "linux", servicePath: SYSTEMD_PATH, alreadyRunning };
266
+ }
267
+
268
+ function detectPlatform(): "macos" | "linux" | "unsupported" {
269
+ if (process.platform === "darwin") return "macos";
270
+ if (process.platform === "linux") return "linux";
271
+ return "unsupported";
272
+ }