@residue/cli 0.0.1

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.
@@ -0,0 +1,224 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync } from "fs";
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+ import { readPending } from "@/lib/pending";
7
+
8
+ let tempDir: string;
9
+
10
+ const cliDir = join(import.meta.dir, "../..");
11
+ const entry = join(cliDir, "src/index.ts");
12
+
13
+ beforeEach(async () => {
14
+ tempDir = await mkdtemp(join(tmpdir(), "residue-hook-test-"));
15
+ const proc = Bun.spawn(["git", "init", tempDir], {
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ });
19
+ await proc.exited;
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await rm(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ function cli(opts: { args: string[]; stdin: string; cwd: string }) {
27
+ return Bun.spawn(["bun", entry, ...opts.args], {
28
+ cwd: opts.cwd,
29
+ stdin: new Blob([opts.stdin]),
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ env: { ...process.env, DEBUG: "residue:*" },
33
+ });
34
+ }
35
+
36
+ function hookInput(overrides: Record<string, unknown> = {}) {
37
+ return JSON.stringify({
38
+ session_id: "cc-session-123",
39
+ transcript_path: "/tmp/transcript.jsonl",
40
+ cwd: tempDir,
41
+ hook_event_name: "SessionStart",
42
+ source: "startup",
43
+ ...overrides,
44
+ });
45
+ }
46
+
47
+ describe("hook claude-code", () => {
48
+ test("creates a session on SessionStart with source=startup", async () => {
49
+ const proc = cli({
50
+ args: ["hook", "claude-code"],
51
+ stdin: hookInput(),
52
+ cwd: tempDir,
53
+ });
54
+ const exitCode = await proc.exited;
55
+ const stderr = await new Response(proc.stderr).text();
56
+
57
+ expect(exitCode).toBe(0);
58
+ expect(stderr).toContain("session started for claude-code");
59
+
60
+ // Check pending session was created
61
+ const pendingPath = join(tempDir, ".residue", "pending.json");
62
+ const sessions = (await readPending(pendingPath))._unsafeUnwrap();
63
+ expect(sessions).toHaveLength(1);
64
+ expect(sessions[0].agent).toBe("claude-code");
65
+ expect(sessions[0].status).toBe("open");
66
+ expect(sessions[0].data_path).toBe("/tmp/transcript.jsonl");
67
+
68
+ // Check state file was created
69
+ const stateFile = join(
70
+ tempDir,
71
+ ".residue",
72
+ "hooks",
73
+ "cc-session-123.state",
74
+ );
75
+ expect(existsSync(stateFile)).toBe(true);
76
+ const residueId = await readFile(stateFile, "utf-8");
77
+ expect(residueId).toBe(sessions[0].id);
78
+ });
79
+
80
+ test("skips SessionStart when source is resume", async () => {
81
+ const proc = cli({
82
+ args: ["hook", "claude-code"],
83
+ stdin: hookInput({ source: "resume" }),
84
+ cwd: tempDir,
85
+ });
86
+ const exitCode = await proc.exited;
87
+
88
+ expect(exitCode).toBe(0);
89
+
90
+ const pendingPath = join(tempDir, ".residue", "pending.json");
91
+ expect(existsSync(pendingPath)).toBe(false);
92
+ });
93
+
94
+ test("skips SessionStart when source is compact", async () => {
95
+ const proc = cli({
96
+ args: ["hook", "claude-code"],
97
+ stdin: hookInput({ source: "compact" }),
98
+ cwd: tempDir,
99
+ });
100
+ const exitCode = await proc.exited;
101
+
102
+ expect(exitCode).toBe(0);
103
+
104
+ const pendingPath = join(tempDir, ".residue", "pending.json");
105
+ expect(existsSync(pendingPath)).toBe(false);
106
+ });
107
+
108
+ test("skips SessionStart when transcript_path is missing", async () => {
109
+ const proc = cli({
110
+ args: ["hook", "claude-code"],
111
+ stdin: hookInput({ transcript_path: "" }),
112
+ cwd: tempDir,
113
+ });
114
+ const exitCode = await proc.exited;
115
+
116
+ expect(exitCode).toBe(0);
117
+
118
+ const pendingPath = join(tempDir, ".residue", "pending.json");
119
+ expect(existsSync(pendingPath)).toBe(false);
120
+ });
121
+
122
+ test("ends a session on SessionEnd", async () => {
123
+ // First start a session
124
+ const startProc = cli({
125
+ args: ["hook", "claude-code"],
126
+ stdin: hookInput(),
127
+ cwd: tempDir,
128
+ });
129
+ await startProc.exited;
130
+
131
+ // Then end it
132
+ const endProc = cli({
133
+ args: ["hook", "claude-code"],
134
+ stdin: hookInput({ hook_event_name: "SessionEnd" }),
135
+ cwd: tempDir,
136
+ });
137
+ const exitCode = await endProc.exited;
138
+ const stderr = await new Response(endProc.stderr).text();
139
+
140
+ expect(exitCode).toBe(0);
141
+ expect(stderr).toContain("ended");
142
+
143
+ // Check session is now ended
144
+ const pendingPath = join(tempDir, ".residue", "pending.json");
145
+ const sessions = (await readPending(pendingPath))._unsafeUnwrap();
146
+ expect(sessions).toHaveLength(1);
147
+ expect(sessions[0].status).toBe("ended");
148
+
149
+ // State file should be removed
150
+ const stateFile = join(
151
+ tempDir,
152
+ ".residue",
153
+ "hooks",
154
+ "cc-session-123.state",
155
+ );
156
+ expect(existsSync(stateFile)).toBe(false);
157
+ });
158
+
159
+ test("handles SessionEnd without prior SessionStart gracefully", async () => {
160
+ const proc = cli({
161
+ args: ["hook", "claude-code"],
162
+ stdin: hookInput({ hook_event_name: "SessionEnd" }),
163
+ cwd: tempDir,
164
+ });
165
+ const exitCode = await proc.exited;
166
+
167
+ expect(exitCode).toBe(0);
168
+
169
+ // No pending sessions should be created
170
+ const pendingPath = join(tempDir, ".residue", "pending.json");
171
+ expect(existsSync(pendingPath)).toBe(false);
172
+ });
173
+
174
+ test("handles full lifecycle: start -> end", async () => {
175
+ const sessionId = "cc-lifecycle-test";
176
+
177
+ // Start
178
+ const startProc = cli({
179
+ args: ["hook", "claude-code"],
180
+ stdin: hookInput({ session_id: sessionId }),
181
+ cwd: tempDir,
182
+ });
183
+ await startProc.exited;
184
+
185
+ // Verify state file exists
186
+ const stateFile = join(tempDir, ".residue", "hooks", `${sessionId}.state`);
187
+ expect(existsSync(stateFile)).toBe(true);
188
+
189
+ // End
190
+ const endProc = cli({
191
+ args: ["hook", "claude-code"],
192
+ stdin: hookInput({
193
+ session_id: sessionId,
194
+ hook_event_name: "SessionEnd",
195
+ }),
196
+ cwd: tempDir,
197
+ });
198
+ await endProc.exited;
199
+
200
+ // State file should be cleaned up
201
+ expect(existsSync(stateFile)).toBe(false);
202
+
203
+ // Session should be ended
204
+ const pendingPath = join(tempDir, ".residue", "pending.json");
205
+ const sessions = (await readPending(pendingPath))._unsafeUnwrap();
206
+ expect(sessions).toHaveLength(1);
207
+ expect(sessions[0].status).toBe("ended");
208
+ expect(sessions[0].data_path).toBe("/tmp/transcript.jsonl");
209
+ });
210
+
211
+ test("skips SessionStart when source is clear", async () => {
212
+ const proc = cli({
213
+ args: ["hook", "claude-code"],
214
+ stdin: hookInput({ source: "clear" }),
215
+ cwd: tempDir,
216
+ });
217
+ const exitCode = await proc.exited;
218
+
219
+ expect(exitCode).toBe(0);
220
+
221
+ const pendingPath = join(tempDir, ".residue", "pending.json");
222
+ expect(existsSync(pendingPath)).toBe(false);
223
+ });
224
+ });