@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,354 @@
1
+ import { err, ok, okAsync, ResultAsync, safeTry } from "neverthrow";
2
+ import { readConfig } from "@/lib/config";
3
+ import { getCommitMeta, getRemoteUrl, parseRemote } from "@/lib/git";
4
+ import type { CommitRef, PendingSession } from "@/lib/pending";
5
+ import {
6
+ getPendingPath,
7
+ getProjectRoot,
8
+ readPending,
9
+ writePending,
10
+ } from "@/lib/pending";
11
+ import { CliError, toCliError } from "@/utils/errors";
12
+ import { createLogger } from "@/utils/logger";
13
+
14
+ const log = createLogger("sync");
15
+
16
+ import { stat } from "fs/promises";
17
+
18
+ const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
19
+
20
+ type CommitPayload = {
21
+ sha: string;
22
+ org: string;
23
+ repo: string;
24
+ message: string;
25
+ author: string;
26
+ committed_at: number;
27
+ branch: string;
28
+ };
29
+
30
+ type UploadUrlResponse = {
31
+ url: string;
32
+ r2_key: string;
33
+ };
34
+
35
+ function requestUploadUrl(opts: {
36
+ workerUrl: string;
37
+ token: string;
38
+ sessionId: string;
39
+ }): ResultAsync<UploadUrlResponse, CliError> {
40
+ return ResultAsync.fromPromise(
41
+ fetch(`${opts.workerUrl}/api/sessions/upload-url`, {
42
+ method: "POST",
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ Authorization: `Bearer ${opts.token}`,
46
+ },
47
+ body: JSON.stringify({ session_id: opts.sessionId }),
48
+ }).then(async (response) => {
49
+ if (!response.ok) {
50
+ throw new Error(`HTTP ${response.status}`);
51
+ }
52
+ return response.json() as Promise<UploadUrlResponse>;
53
+ }),
54
+ toCliError({
55
+ message: "Failed to request upload URL",
56
+ code: "NETWORK_ERROR",
57
+ }),
58
+ );
59
+ }
60
+
61
+ function uploadToPresignedUrl(opts: {
62
+ url: string;
63
+ data: string;
64
+ }): ResultAsync<void, CliError> {
65
+ return ResultAsync.fromPromise(
66
+ fetch(opts.url, {
67
+ method: "PUT",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: opts.data,
70
+ }).then((response) => {
71
+ if (!response.ok) {
72
+ throw new Error(`R2 upload failed: HTTP ${response.status}`);
73
+ }
74
+ }),
75
+ toCliError({ message: "Direct R2 upload failed", code: "NETWORK_ERROR" }),
76
+ );
77
+ }
78
+
79
+ function postSessionMetadata(opts: {
80
+ workerUrl: string;
81
+ token: string;
82
+ session: {
83
+ id: string;
84
+ agent: string;
85
+ agent_version: string;
86
+ status: string;
87
+ };
88
+ commits: CommitPayload[];
89
+ }): ResultAsync<void, CliError> {
90
+ return ResultAsync.fromPromise(
91
+ fetch(`${opts.workerUrl}/api/sessions`, {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ Authorization: `Bearer ${opts.token}`,
96
+ },
97
+ body: JSON.stringify({
98
+ session: opts.session,
99
+ commits: opts.commits,
100
+ }),
101
+ }).then((response) => {
102
+ if (!response.ok) {
103
+ throw new Error(`HTTP ${response.status}`);
104
+ }
105
+ }),
106
+ toCliError({ message: "Metadata upload failed", code: "NETWORK_ERROR" }),
107
+ );
108
+ }
109
+
110
+ function readSessionData(
111
+ dataPath: string,
112
+ ): ResultAsync<string | null, CliError> {
113
+ return ResultAsync.fromPromise(
114
+ (async () => {
115
+ const file = Bun.file(dataPath);
116
+ const isExists = await file.exists();
117
+ if (!isExists) return null;
118
+ return file.text();
119
+ })(),
120
+ toCliError({ message: "Failed to read session data", code: "IO_ERROR" }),
121
+ );
122
+ }
123
+
124
+ function buildCommitMeta(opts: {
125
+ commitRefs: CommitRef[];
126
+ org: string;
127
+ repo: string;
128
+ }): ResultAsync<CommitPayload[], CliError> {
129
+ return ResultAsync.fromSafePromise(
130
+ (async () => {
131
+ const commits: CommitPayload[] = [];
132
+ for (const ref of opts.commitRefs) {
133
+ const metaResult = await getCommitMeta(ref.sha);
134
+ if (metaResult.isErr()) {
135
+ log.warn(metaResult.error);
136
+ continue;
137
+ }
138
+ commits.push({
139
+ sha: ref.sha,
140
+ org: opts.org,
141
+ repo: opts.repo,
142
+ message: metaResult.value.message,
143
+ author: metaResult.value.author,
144
+ committed_at: metaResult.value.committed_at,
145
+ branch: ref.branch,
146
+ });
147
+ }
148
+ return commits;
149
+ })(),
150
+ );
151
+ }
152
+
153
+ function getFileMtimeMs(path: string): ResultAsync<number | null, CliError> {
154
+ return ResultAsync.fromPromise(
155
+ stat(path).then((s) => s.mtimeMs),
156
+ toCliError({ message: "Failed to stat file", code: "IO_ERROR" }),
157
+ ).orElse(() => okAsync(null));
158
+ }
159
+
160
+ /**
161
+ * Mark open sessions as ended if their data file hasn't been modified
162
+ * in the last 30 minutes. This handles dangling sessions from crashed
163
+ * or closed agent processes that never called session-end.
164
+ */
165
+ function closeStaleOpenSessions(opts: {
166
+ sessions: PendingSession[];
167
+ }): ResultAsync<PendingSession[], CliError> {
168
+ const now = Date.now();
169
+ const openSessions = opts.sessions.filter((s) => s.status === "open");
170
+
171
+ if (openSessions.length === 0) {
172
+ return okAsync(opts.sessions);
173
+ }
174
+
175
+ const checks = openSessions.map((session) =>
176
+ getFileMtimeMs(session.data_path).map((mtimeMs) => {
177
+ if (mtimeMs === null) {
178
+ session.status = "ended";
179
+ log.debug(
180
+ "auto-closed session %s (data file not accessible)",
181
+ session.id,
182
+ );
183
+ } else {
184
+ const msSinceModified = now - mtimeMs;
185
+ if (msSinceModified > STALE_THRESHOLD_MS) {
186
+ session.status = "ended";
187
+ log.debug(
188
+ "auto-closed stale session %s (data file unchanged for %dm)",
189
+ session.id,
190
+ Math.round(msSinceModified / 60_000),
191
+ );
192
+ }
193
+ }
194
+ }),
195
+ );
196
+
197
+ return ResultAsync.combine(checks).map(() => opts.sessions);
198
+ }
199
+
200
+ function syncSessions(opts: {
201
+ sessions: PendingSession[];
202
+ workerUrl: string;
203
+ token: string;
204
+ org: string;
205
+ repo: string;
206
+ }): ResultAsync<PendingSession[], CliError> {
207
+ return ResultAsync.fromSafePromise(
208
+ (async () => {
209
+ const remaining: PendingSession[] = [];
210
+
211
+ for (const session of opts.sessions) {
212
+ if (session.commits.length === 0) {
213
+ remaining.push(session);
214
+ continue;
215
+ }
216
+
217
+ const dataResult = await readSessionData(session.data_path);
218
+ if (dataResult.isErr()) {
219
+ log.warn(dataResult.error);
220
+ remaining.push(session);
221
+ continue;
222
+ }
223
+
224
+ const data = dataResult.value;
225
+ if (data === null) {
226
+ log.warn(
227
+ `dropping session ${session.id}: data file missing at ${session.data_path}`,
228
+ );
229
+ continue;
230
+ }
231
+
232
+ const commitsResult = await buildCommitMeta({
233
+ commitRefs: session.commits,
234
+ org: opts.org,
235
+ repo: opts.repo,
236
+ });
237
+ if (commitsResult.isErr()) {
238
+ log.warn(commitsResult.error);
239
+ remaining.push(session);
240
+ continue;
241
+ }
242
+
243
+ // Step 1: Get a presigned URL from the worker
244
+ const uploadUrlResult = await requestUploadUrl({
245
+ workerUrl: opts.workerUrl,
246
+ token: opts.token,
247
+ sessionId: session.id,
248
+ });
249
+
250
+ if (uploadUrlResult.isErr()) {
251
+ log.warn(
252
+ `failed to get upload URL for session ${session.id}: ${uploadUrlResult.error.message}`,
253
+ );
254
+ remaining.push(session);
255
+ continue;
256
+ }
257
+
258
+ // Step 2: Upload session data directly to R2
259
+ const directUploadResult = await uploadToPresignedUrl({
260
+ url: uploadUrlResult.value.url,
261
+ data,
262
+ });
263
+
264
+ if (directUploadResult.isErr()) {
265
+ log.warn(
266
+ `R2 upload failed for session ${session.id}: ${directUploadResult.error.message}`,
267
+ );
268
+ remaining.push(session);
269
+ continue;
270
+ }
271
+
272
+ log.debug("uploaded session %s data directly to R2", session.id);
273
+
274
+ // Step 3: POST metadata only (no inline data)
275
+ const metadataResult = await postSessionMetadata({
276
+ workerUrl: opts.workerUrl,
277
+ token: opts.token,
278
+ session: {
279
+ id: session.id,
280
+ agent: session.agent,
281
+ agent_version: session.agent_version,
282
+ status: session.status,
283
+ },
284
+ commits: commitsResult.value,
285
+ });
286
+
287
+ if (metadataResult.isErr()) {
288
+ log.warn(
289
+ `metadata upload failed for session ${session.id}: ${metadataResult.error.message}`,
290
+ );
291
+ remaining.push(session);
292
+ continue;
293
+ }
294
+
295
+ if (session.status === "open") {
296
+ remaining.push(session);
297
+ }
298
+
299
+ log.debug("synced session %s", session.id);
300
+ }
301
+
302
+ return remaining;
303
+ })(),
304
+ );
305
+ }
306
+
307
+ function resolveRemote(
308
+ remoteUrl?: string,
309
+ ): ResultAsync<{ org: string; repo: string }, CliError> {
310
+ if (remoteUrl && remoteUrl.length > 0) {
311
+ const result = parseRemote(remoteUrl);
312
+ if (result.isOk()) {
313
+ return okAsync(result.value);
314
+ }
315
+ }
316
+ return getRemoteUrl().andThen(parseRemote);
317
+ }
318
+
319
+ export function sync(opts?: {
320
+ remoteUrl?: string;
321
+ }): ResultAsync<void, CliError> {
322
+ return safeTry(async function* () {
323
+ const config = yield* readConfig();
324
+ if (!config) {
325
+ return err(
326
+ new CliError({
327
+ message: "Not configured. Run 'residue login' first.",
328
+ code: "CONFIG_MISSING",
329
+ }),
330
+ );
331
+ }
332
+
333
+ const projectRoot = yield* getProjectRoot();
334
+ const pendingPath = yield* getPendingPath(projectRoot);
335
+ const sessions = yield* readPending(pendingPath);
336
+
337
+ if (sessions.length === 0) {
338
+ return ok(undefined);
339
+ }
340
+
341
+ const updatedSessions = yield* closeStaleOpenSessions({ sessions });
342
+ const { org, repo } = yield* resolveRemote(opts?.remoteUrl);
343
+ const remaining = yield* syncSessions({
344
+ sessions: updatedSessions,
345
+ workerUrl: config.worker_url,
346
+ token: config.token,
347
+ org,
348
+ repo,
349
+ });
350
+
351
+ yield* writePending({ path: pendingPath, sessions: remaining });
352
+ return ok(undefined);
353
+ });
354
+ }
package/src/index.ts ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander";
4
+ import { capture } from "@/commands/capture";
5
+ import { hookClaudeCode } from "@/commands/hook";
6
+ import { init } from "@/commands/init";
7
+ import { login } from "@/commands/login";
8
+ import { push } from "@/commands/push";
9
+ import { sessionEnd } from "@/commands/session-end";
10
+ import { sessionStart } from "@/commands/session-start";
11
+ import { setup } from "@/commands/setup";
12
+ import { sync } from "@/commands/sync";
13
+ import { wrapCommand, wrapHookCommand } from "@/utils/errors";
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name("residue")
19
+ .description("Capture AI agent conversations linked to git commits")
20
+ .version("0.0.1");
21
+
22
+ program
23
+ .command("login")
24
+ .description("Save worker URL and auth token")
25
+ .requiredOption("--url <worker_url>", "Worker URL")
26
+ .requiredOption("--token <auth_token>", "Auth token")
27
+ .action(
28
+ wrapCommand((opts: { url: string; token: string }) =>
29
+ login({ url: opts.url, token: opts.token }),
30
+ ),
31
+ );
32
+
33
+ program
34
+ .command("init")
35
+ .description("Install git hooks in current repo")
36
+ .action(wrapCommand(() => init()));
37
+
38
+ program
39
+ .command("setup")
40
+ .description("Configure an agent adapter for this project")
41
+ .argument("<agent>", "Agent to set up (claude-code, pi)")
42
+ .action(wrapCommand((agent: string) => setup({ agent })));
43
+
44
+ const hook = program
45
+ .command("hook")
46
+ .description("Agent hook handlers (called by agent plugins)");
47
+
48
+ hook
49
+ .command("claude-code")
50
+ .description("Handle Claude Code hook events (reads JSON from stdin)")
51
+ .action(wrapHookCommand(() => hookClaudeCode()));
52
+
53
+ const session = program.command("session").description("Manage agent sessions");
54
+
55
+ session
56
+ .command("start")
57
+ .description("Start tracking an agent session")
58
+ .requiredOption("--agent <name>", "Agent name")
59
+ .requiredOption("--data <path>", "Path to raw session file")
60
+ .option("--agent-version <semver>", "Agent version", "unknown")
61
+ .action(
62
+ wrapCommand((opts: { agent: string; data: string; agentVersion: string }) =>
63
+ sessionStart({
64
+ agent: opts.agent,
65
+ data: opts.data,
66
+ agentVersion: opts.agentVersion,
67
+ }),
68
+ ),
69
+ );
70
+
71
+ session
72
+ .command("end")
73
+ .description("Mark an agent session as ended")
74
+ .requiredOption("--id <session-id>", "Session ID to end")
75
+ .action(wrapCommand((opts: { id: string }) => sessionEnd({ id: opts.id })));
76
+
77
+ program
78
+ .command("capture")
79
+ .description(
80
+ "Tag pending sessions with current commit SHA (called by post-commit hook)",
81
+ )
82
+ .action(wrapHookCommand(() => capture()));
83
+
84
+ program
85
+ .command("sync")
86
+ .description("Upload pending sessions to worker (called by pre-push hook)")
87
+ .option("--remote-url <url>", "Remote URL (passed by pre-push hook)")
88
+ .action(
89
+ wrapHookCommand((opts: { remoteUrl?: string }) =>
90
+ sync({ remoteUrl: opts.remoteUrl }),
91
+ ),
92
+ );
93
+
94
+ program
95
+ .command("push")
96
+ .description("Upload pending sessions to worker (manual trigger)")
97
+ .action(wrapCommand(() => push()));
98
+
99
+ program.parse();
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Config management for the residue CLI.
3
+ * Manages ~/.residue/config (JSON file).
4
+ */
5
+
6
+ import { mkdir } from "fs/promises";
7
+ import { ResultAsync } from "neverthrow";
8
+ import { join } from "path";
9
+ import type { CliError } from "@/utils/errors";
10
+ import { toCliError } from "@/utils/errors";
11
+
12
+ export type ResidueConfig = {
13
+ worker_url: string;
14
+ token: string;
15
+ };
16
+
17
+ function home(): string {
18
+ return process.env.HOME || process.env.USERPROFILE || "/";
19
+ }
20
+
21
+ export function getConfigDir(): string {
22
+ return join(home(), ".residue");
23
+ }
24
+
25
+ export function getConfigPath(): string {
26
+ return join(getConfigDir(), "config");
27
+ }
28
+
29
+ export function readConfig(): ResultAsync<ResidueConfig | null, CliError> {
30
+ return ResultAsync.fromPromise(
31
+ (async () => {
32
+ const path = getConfigPath();
33
+ const file = Bun.file(path);
34
+ const isExists = await file.exists();
35
+ if (!isExists) return null;
36
+ const text = await file.text();
37
+ return JSON.parse(text) as ResidueConfig;
38
+ })(),
39
+ toCliError({ message: "Failed to read config", code: "CONFIG_ERROR" }),
40
+ );
41
+ }
42
+
43
+ export function writeConfig(
44
+ config: ResidueConfig,
45
+ ): ResultAsync<void, CliError> {
46
+ return ResultAsync.fromPromise(
47
+ (async () => {
48
+ const dir = getConfigDir();
49
+ await mkdir(dir, { recursive: true });
50
+ await Bun.write(getConfigPath(), JSON.stringify(config, null, 2));
51
+ })(),
52
+ toCliError({ message: "Failed to write config", code: "CONFIG_ERROR" }),
53
+ );
54
+ }
55
+
56
+ export function configExists(): ResultAsync<boolean, CliError> {
57
+ return ResultAsync.fromPromise(
58
+ Bun.file(getConfigPath()).exists(),
59
+ toCliError({ message: "Failed to check config", code: "CONFIG_ERROR" }),
60
+ );
61
+ }
package/src/lib/git.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Git utility functions for the residue CLI.
3
+ */
4
+ import { err, ok, type Result, ResultAsync } from "neverthrow";
5
+ import { CliError, toCliError } from "@/utils/errors";
6
+
7
+ function runGitCommand(opts: {
8
+ args: string[];
9
+ errorMessage: string;
10
+ }): ResultAsync<string, CliError> {
11
+ return ResultAsync.fromPromise(
12
+ (async () => {
13
+ const proc = Bun.spawn(["git", ...opts.args], {
14
+ stdout: "pipe",
15
+ stderr: "pipe",
16
+ });
17
+ const exitCode = await proc.exited;
18
+ if (exitCode !== 0) {
19
+ const stderr = await new Response(proc.stderr).text();
20
+ throw new Error(stderr.trim() || `exit code ${exitCode}`);
21
+ }
22
+ return (await new Response(proc.stdout).text()).trim();
23
+ })(),
24
+ toCliError({ message: opts.errorMessage, code: "GIT_ERROR" }),
25
+ );
26
+ }
27
+
28
+ export function parseRemote(
29
+ remoteUrl: string,
30
+ ): Result<{ org: string; repo: string }, CliError> {
31
+ const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
32
+ if (!match) {
33
+ return err(
34
+ new CliError({
35
+ message: `Cannot parse git remote URL: ${remoteUrl}`,
36
+ code: "GIT_PARSE_ERROR",
37
+ }),
38
+ );
39
+ }
40
+ return ok({ org: match[1], repo: match[2] });
41
+ }
42
+
43
+ export function getRemoteUrl(): ResultAsync<string, CliError> {
44
+ return runGitCommand({
45
+ args: ["remote", "get-url", "origin"],
46
+ errorMessage: "Failed to get git remote URL",
47
+ });
48
+ }
49
+
50
+ export function getCurrentBranch(): ResultAsync<string, CliError> {
51
+ return runGitCommand({
52
+ args: ["rev-parse", "--abbrev-ref", "HEAD"],
53
+ errorMessage: "Failed to get current branch",
54
+ });
55
+ }
56
+
57
+ export function getCurrentSha(): ResultAsync<string, CliError> {
58
+ return runGitCommand({
59
+ args: ["rev-parse", "HEAD"],
60
+ errorMessage: "Failed to get current commit SHA",
61
+ });
62
+ }
63
+
64
+ export function getCommitMeta(
65
+ sha: string,
66
+ ): ResultAsync<
67
+ { message: string; author: string; committed_at: number },
68
+ CliError
69
+ > {
70
+ return runGitCommand({
71
+ args: ["log", "-1", "--format=%s%n%an%n%ct", sha],
72
+ errorMessage: `Failed to get commit metadata for ${sha}`,
73
+ }).map((text) => {
74
+ const lines = text.split("\n");
75
+ return {
76
+ message: lines[0] || "",
77
+ author: lines[1] || "",
78
+ committed_at: parseInt(lines[2] || "0", 10),
79
+ };
80
+ });
81
+ }
82
+
83
+ export function isGitRepo(): ResultAsync<boolean, CliError> {
84
+ return ResultAsync.fromPromise(
85
+ (async () => {
86
+ const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
87
+ stdout: "pipe",
88
+ stderr: "pipe",
89
+ });
90
+ const exitCode = await proc.exited;
91
+ return exitCode === 0;
92
+ })(),
93
+ toCliError({ message: "Failed to check git repo", code: "GIT_ERROR" }),
94
+ );
95
+ }