@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.
- package/adapters/pi/extension.ts.txt +137 -0
- package/dist/index.js +4443 -0
- package/dist/residue +0 -0
- package/package.json +24 -0
- package/src/commands/capture.ts +49 -0
- package/src/commands/hook.ts +222 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/login.ts +26 -0
- package/src/commands/push.ts +3 -0
- package/src/commands/session-end.ts +38 -0
- package/src/commands/session-start.ts +35 -0
- package/src/commands/setup.ts +148 -0
- package/src/commands/sync.ts +354 -0
- package/src/index.ts +99 -0
- package/src/lib/config.ts +61 -0
- package/src/lib/git.ts +95 -0
- package/src/lib/pending.ts +190 -0
- package/src/types/text-import.d.ts +4 -0
- package/src/utils/errors.ts +75 -0
- package/src/utils/logger.ts +51 -0
- package/test/commands/capture.test.ts +206 -0
- package/test/commands/hook.test.ts +224 -0
- package/test/commands/sync.test.ts +540 -0
- package/tsconfig.json +14 -0
|
@@ -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
|
+
});
|