@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,190 @@
1
+ /**
2
+ * Pending queue management for the residue CLI.
3
+ * Manages .residue/pending.json in the project root.
4
+ */
5
+
6
+ import { mkdir } from "fs/promises";
7
+ import { errAsync, ResultAsync } from "neverthrow";
8
+ import { join } from "path";
9
+ import { CliError, toCliError } from "@/utils/errors";
10
+
11
+ export type CommitRef = {
12
+ sha: string;
13
+ branch: string;
14
+ };
15
+
16
+ export type PendingSession = {
17
+ id: string;
18
+ agent: string;
19
+ agent_version: string;
20
+ status: "open" | "ended";
21
+ data_path: string;
22
+ commits: CommitRef[];
23
+ };
24
+
25
+ /**
26
+ * Get the project root via git rev-parse --show-toplevel.
27
+ */
28
+ export function getProjectRoot(): ResultAsync<string, CliError> {
29
+ return ResultAsync.fromPromise(
30
+ (async () => {
31
+ const proc = Bun.spawn(["git", "rev-parse", "--show-toplevel"], {
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ });
35
+ const exitCode = await proc.exited;
36
+ if (exitCode !== 0) {
37
+ throw new Error("not a git repository");
38
+ }
39
+ return (await new Response(proc.stdout).text()).trim();
40
+ })(),
41
+ toCliError({ message: "Not a git repository", code: "GIT_ERROR" }),
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Get the .residue directory path, creating it if needed.
47
+ */
48
+ export function getResidueDir(
49
+ projectRoot: string,
50
+ ): ResultAsync<string, CliError> {
51
+ const residueDir = join(projectRoot, ".residue");
52
+ return ResultAsync.fromPromise(
53
+ (async () => {
54
+ await mkdir(residueDir, { recursive: true });
55
+ return residueDir;
56
+ })(),
57
+ toCliError({
58
+ message: "Failed to create .residue directory",
59
+ code: "IO_ERROR",
60
+ }),
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Get the path to .residue/pending.json, creating the directory if needed.
66
+ */
67
+ export function getPendingPath(
68
+ projectRoot: string,
69
+ ): ResultAsync<string, CliError> {
70
+ return getResidueDir(projectRoot).map((residueDir) =>
71
+ join(residueDir, "pending.json"),
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Migrate old format where commits was string[] to CommitRef[].
77
+ */
78
+ function migratePending(sessions: PendingSession[]): PendingSession[] {
79
+ for (const session of sessions) {
80
+ if (session.commits.length > 0 && typeof session.commits[0] === "string") {
81
+ session.commits = (session.commits as unknown as string[]).map((sha) => ({
82
+ sha,
83
+ branch: "unknown",
84
+ }));
85
+ }
86
+ }
87
+ return sessions;
88
+ }
89
+
90
+ /**
91
+ * Read pending sessions from disk. Returns [] if file doesn't exist.
92
+ * Handles backward compat: old format had commits as string[] (just SHAs).
93
+ */
94
+ export function readPending(
95
+ pendingPath: string,
96
+ ): ResultAsync<PendingSession[], CliError> {
97
+ return ResultAsync.fromPromise(
98
+ (async () => {
99
+ const file = Bun.file(pendingPath);
100
+ const isExists = await file.exists();
101
+ if (!isExists) return [];
102
+ const text = await file.text();
103
+ const sessions = JSON.parse(text) as PendingSession[];
104
+ return migratePending(sessions);
105
+ })(),
106
+ toCliError({
107
+ message: "Failed to read pending queue",
108
+ code: "STATE_ERROR",
109
+ }),
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Write pending sessions to disk.
115
+ */
116
+ export function writePending(opts: {
117
+ path: string;
118
+ sessions: PendingSession[];
119
+ }): ResultAsync<void, CliError> {
120
+ return ResultAsync.fromPromise(
121
+ (async () => {
122
+ await Bun.write(opts.path, JSON.stringify(opts.sessions, null, 2));
123
+ })(),
124
+ toCliError({
125
+ message: "Failed to write pending queue",
126
+ code: "STATE_ERROR",
127
+ }),
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Add a session to the pending queue.
133
+ */
134
+ export function addSession(opts: {
135
+ path: string;
136
+ session: PendingSession;
137
+ }): ResultAsync<void, CliError> {
138
+ return readPending(opts.path).andThen((sessions) => {
139
+ sessions.push(opts.session);
140
+ return writePending({ path: opts.path, sessions });
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Update a session by ID with partial updates.
146
+ */
147
+ export function updateSession(opts: {
148
+ path: string;
149
+ id: string;
150
+ updates: Partial<PendingSession>;
151
+ }): ResultAsync<void, CliError> {
152
+ return readPending(opts.path).andThen((sessions) => {
153
+ const index = sessions.findIndex((s) => s.id === opts.id);
154
+ if (index === -1) {
155
+ return errAsync(
156
+ new CliError({
157
+ message: `Session not found: ${opts.id}`,
158
+ code: "SESSION_NOT_FOUND",
159
+ }),
160
+ );
161
+ }
162
+ sessions[index] = { ...sessions[index], ...opts.updates };
163
+ return writePending({ path: opts.path, sessions });
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Remove a session by ID.
169
+ */
170
+ export function removeSession(opts: {
171
+ path: string;
172
+ id: string;
173
+ }): ResultAsync<void, CliError> {
174
+ return readPending(opts.path).andThen((sessions) => {
175
+ const filtered = sessions.filter((s) => s.id !== opts.id);
176
+ return writePending({ path: opts.path, sessions: filtered });
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Get a session by ID.
182
+ */
183
+ export function getSession(opts: {
184
+ path: string;
185
+ id: string;
186
+ }): ResultAsync<PendingSession | undefined, CliError> {
187
+ return readPending(opts.path).map((sessions) =>
188
+ sessions.find((s) => s.id === opts.id),
189
+ );
190
+ }
@@ -0,0 +1,4 @@
1
+ declare module "*.txt" {
2
+ const content: string;
3
+ export default content;
4
+ }
@@ -0,0 +1,75 @@
1
+ import type { ResultAsync } from "neverthrow";
2
+ import { createLogger } from "@/utils/logger";
3
+
4
+ const log = createLogger("cli");
5
+
6
+ type CliErrorCode =
7
+ | "GIT_ERROR"
8
+ | "GIT_PARSE_ERROR"
9
+ | "CONFIG_ERROR"
10
+ | "CONFIG_MISSING"
11
+ | "STATE_ERROR"
12
+ | "SESSION_NOT_FOUND"
13
+ | "IO_ERROR"
14
+ | "NETWORK_ERROR"
15
+ | "VALIDATION_ERROR"
16
+ | "PARSE_ERROR";
17
+
18
+ class CliError extends Error {
19
+ readonly _tag = "CliError" as const;
20
+ readonly code: CliErrorCode;
21
+
22
+ constructor(opts: { message: string; code: CliErrorCode; cause?: unknown }) {
23
+ super(opts.message, { cause: opts.cause });
24
+ this.name = "CliError";
25
+ this.code = opts.code;
26
+ }
27
+ }
28
+
29
+ const isCliError = (error: unknown): error is CliError => {
30
+ return error instanceof CliError;
31
+ };
32
+
33
+ /**
34
+ * Creates an error handler for ResultAsync.fromPromise that wraps
35
+ * the caught value into a CliError.
36
+ */
37
+ const toCliError =
38
+ (opts: { message: string; code: CliErrorCode }) =>
39
+ (cause: unknown): CliError =>
40
+ new CliError({ ...opts, cause });
41
+
42
+ type CommandFn = (...args: never[]) => ResultAsync<void, CliError>;
43
+
44
+ // Wraps a command that returns ResultAsync<void, CliError> with consistent error handling.
45
+ // On Err, prints to stderr and exits with the given code.
46
+ function wrapCommand<T extends CommandFn>(
47
+ fn: T,
48
+ opts?: { exitCode?: number },
49
+ ): (...args: Parameters<T>) => Promise<void> {
50
+ const exitCode = opts?.exitCode ?? 1;
51
+ return async (...args: Parameters<T>) => {
52
+ const result = await fn(...args);
53
+ if (result.isErr()) {
54
+ log.error(result.error);
55
+ process.exit(exitCode);
56
+ }
57
+ };
58
+ }
59
+
60
+ // Wraps a command that should never block git operations (hooks).
61
+ // Errors are printed as warnings and exit 0 so git proceeds.
62
+ function wrapHookCommand<T extends CommandFn>(
63
+ fn: T,
64
+ ): (...args: Parameters<T>) => Promise<void> {
65
+ return wrapCommand(fn, { exitCode: 0 });
66
+ }
67
+
68
+ export {
69
+ CliError,
70
+ type CliErrorCode,
71
+ isCliError,
72
+ toCliError,
73
+ wrapCommand,
74
+ wrapHookCommand,
75
+ };
@@ -0,0 +1,51 @@
1
+ import createDebug from "debug";
2
+
3
+ const BASE_NAMESPACE = "residue";
4
+
5
+ type Loggable = string | Error;
6
+
7
+ function formatMessage(value: Loggable): string {
8
+ if (typeof value === "string") return value;
9
+ return value.message;
10
+ }
11
+
12
+ /**
13
+ * Lightweight CLI logger wrapping the `debug` package.
14
+ *
15
+ * Levels:
16
+ * log.debug(msg) -- only visible when DEBUG=residue:* or DEBUG=residue:<ns>
17
+ * log.info(msg) -- always printed to stderr, plain message
18
+ * log.warn(msg | error) -- always printed to stderr, prefixed with "Warning:"
19
+ * log.error(msg | error)-- always printed to stderr, prefixed with "Error:"
20
+ *
21
+ * warn() and error() accept a string or an Error (including CliError).
22
+ * When given an Error, the .message is extracted automatically.
23
+ *
24
+ * All output goes to stderr so stdout stays clean for machine-readable
25
+ * data (e.g. session IDs piped back to agent adapters).
26
+ */
27
+ function createLogger(namespace: string) {
28
+ const debug = createDebug(`${BASE_NAMESPACE}:${namespace}`);
29
+
30
+ return {
31
+ /** Diagnostic message. Only visible when DEBUG includes this namespace. */
32
+ debug,
33
+
34
+ /** User-facing status message. Always printed to stderr. */
35
+ info(message: string) {
36
+ process.stderr.write(`${message}\n`);
37
+ },
38
+
39
+ /** Warning. Always printed to stderr. Accepts a string or Error. */
40
+ warn(value: Loggable) {
41
+ process.stderr.write(`Warning: ${formatMessage(value)}\n`);
42
+ },
43
+
44
+ /** Error. Always printed to stderr. Accepts a string or Error. */
45
+ error(value: Loggable) {
46
+ process.stderr.write(`Error: ${formatMessage(value)}\n`);
47
+ },
48
+ };
49
+ }
50
+
51
+ export { createLogger, type Loggable };
@@ -0,0 +1,206 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { readPending } from "@/lib/pending";
6
+
7
+ let tempDir: string;
8
+
9
+ const cliDir = join(import.meta.dir, "../..");
10
+ const entry = join(cliDir, "src/index.ts");
11
+
12
+ async function gitExec(args: string[]) {
13
+ const proc = Bun.spawn(["git", ...args], {
14
+ cwd: tempDir,
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ });
18
+ await proc.exited;
19
+ return (await new Response(proc.stdout).text()).trim();
20
+ }
21
+
22
+ beforeEach(async () => {
23
+ tempDir = await mkdtemp(join(tmpdir(), "residue-capture-test-"));
24
+ await gitExec(["init"]);
25
+ await gitExec(["config", "user.email", "test@test.com"]);
26
+ await gitExec(["config", "user.name", "Test"]);
27
+ await writeFile(join(tempDir, "README.md"), "init");
28
+ await gitExec(["add", "."]);
29
+ await gitExec(["commit", "-m", "initial"]);
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await rm(tempDir, { recursive: true, force: true });
34
+ });
35
+
36
+ function cli(args: string[]) {
37
+ return Bun.spawn(["bun", entry, ...args], {
38
+ cwd: tempDir,
39
+ stdout: "pipe",
40
+ stderr: "pipe",
41
+ env: { ...process.env },
42
+ });
43
+ }
44
+
45
+ describe("capture command", () => {
46
+ test("tags pending sessions with current commit SHA", async () => {
47
+ const startProc = cli([
48
+ "session",
49
+ "start",
50
+ "--agent",
51
+ "claude-code",
52
+ "--data",
53
+ "/tmp/s.jsonl",
54
+ ]);
55
+ await startProc.exited;
56
+
57
+ await writeFile(join(tempDir, "file.txt"), "hello");
58
+ await gitExec(["add", "."]);
59
+ await gitExec(["commit", "-m", "test commit"]);
60
+ const sha = await gitExec(["rev-parse", "HEAD"]);
61
+
62
+ const captureProc = cli(["capture"]);
63
+ const exitCode = await captureProc.exited;
64
+
65
+ expect(exitCode).toBe(0);
66
+
67
+ const pendingPath = join(tempDir, ".residue/pending.json");
68
+ const sessions = (await readPending(pendingPath))._unsafeUnwrap();
69
+ expect(sessions).toHaveLength(1);
70
+ expect(sessions[0].commits.some((c) => c.sha === sha)).toBe(true);
71
+ expect(sessions[0].commits[0].branch).toBeDefined();
72
+ });
73
+
74
+ test("does not duplicate SHA on repeated capture", async () => {
75
+ const startProc = cli([
76
+ "session",
77
+ "start",
78
+ "--agent",
79
+ "claude-code",
80
+ "--data",
81
+ "/tmp/s.jsonl",
82
+ ]);
83
+ await startProc.exited;
84
+
85
+ const c1 = cli(["capture"]);
86
+ await c1.exited;
87
+ const c2 = cli(["capture"]);
88
+ await c2.exited;
89
+
90
+ const pendingPath = join(tempDir, ".residue/pending.json");
91
+ const sessions = (await readPending(pendingPath))._unsafeUnwrap();
92
+ const sha = await gitExec(["rev-parse", "HEAD"]);
93
+ const count = sessions[0].commits.filter((c) => c.sha === sha).length;
94
+ expect(count).toBe(1);
95
+ });
96
+
97
+ test("tags ended sessions with zero commits (first capture after ending)", async () => {
98
+ const s1 = cli([
99
+ "session",
100
+ "start",
101
+ "--agent",
102
+ "claude-code",
103
+ "--data",
104
+ "/tmp/s1.jsonl",
105
+ ]);
106
+ await s1.exited;
107
+ const id1 = (await new Response(s1.stdout).text()).trim();
108
+
109
+ const s2 = cli([
110
+ "session",
111
+ "start",
112
+ "--agent",
113
+ "claude-code",
114
+ "--data",
115
+ "/tmp/s2.jsonl",
116
+ ]);
117
+ await s2.exited;
118
+
119
+ const endProc = cli(["session", "end", "--id", id1]);
120
+ await endProc.exited;
121
+
122
+ await writeFile(join(tempDir, "file.txt"), "hello");
123
+ await gitExec(["add", "."]);
124
+ await gitExec(["commit", "-m", "test commit"]);
125
+ const sha = await gitExec(["rev-parse", "HEAD"]);
126
+
127
+ const captureProc = cli(["capture"]);
128
+ await captureProc.exited;
129
+
130
+ const pendingPath = join(tempDir, ".residue/pending.json");
131
+ const sessions = (await readPending(pendingPath))._unsafeUnwrap();
132
+
133
+ expect(sessions).toHaveLength(2);
134
+ // Ended session with zero commits gets tagged (first capture after ending)
135
+ const ended = sessions.find((s: { id: string }) => s.id === id1);
136
+ expect(ended!.commits.some((c) => c.sha === sha)).toBe(true);
137
+ // Open session always gets tagged
138
+ const open = sessions.find((s: { id: string }) => s.id !== id1);
139
+ expect(open!.commits.some((c) => c.sha === sha)).toBe(true);
140
+ });
141
+
142
+ test("does not tag ended sessions that already have commits", async () => {
143
+ const s1 = cli([
144
+ "session",
145
+ "start",
146
+ "--agent",
147
+ "claude-code",
148
+ "--data",
149
+ "/tmp/s1.jsonl",
150
+ ]);
151
+ await s1.exited;
152
+ const id1 = (await new Response(s1.stdout).text()).trim();
153
+
154
+ // First commit while session is open -- capture tags it
155
+ await writeFile(join(tempDir, "file.txt"), "hello");
156
+ await gitExec(["add", "."]);
157
+ await gitExec(["commit", "-m", "commit 1"]);
158
+ const sha1 = await gitExec(["rev-parse", "HEAD"]);
159
+
160
+ const c1 = cli(["capture"]);
161
+ await c1.exited;
162
+
163
+ // End the session
164
+ const endProc = cli(["session", "end", "--id", id1]);
165
+ await endProc.exited;
166
+
167
+ // Second commit after session ended -- capture should NOT tag the ended session
168
+ await writeFile(join(tempDir, "file2.txt"), "world");
169
+ await gitExec(["add", "."]);
170
+ await gitExec(["commit", "-m", "commit 2"]);
171
+ const sha2 = await gitExec(["rev-parse", "HEAD"]);
172
+
173
+ const c2 = cli(["capture"]);
174
+ await c2.exited;
175
+
176
+ const pendingPath = join(tempDir, ".residue/pending.json");
177
+ const sessions = (await readPending(pendingPath))._unsafeUnwrap();
178
+
179
+ expect(sessions).toHaveLength(1);
180
+ const ended = sessions.find((s: { id: string }) => s.id === id1);
181
+ expect(ended!.commits.some((c) => c.sha === sha1)).toBe(true);
182
+ expect(ended!.commits.some((c) => c.sha === sha2)).toBe(false);
183
+ });
184
+
185
+ test("records branch name with commit SHA", async () => {
186
+ const startProc = cli([
187
+ "session",
188
+ "start",
189
+ "--agent",
190
+ "claude-code",
191
+ "--data",
192
+ "/tmp/s.jsonl",
193
+ ]);
194
+ await startProc.exited;
195
+
196
+ const captureProc = cli(["capture"]);
197
+ await captureProc.exited;
198
+
199
+ const pendingPath = join(tempDir, ".residue/pending.json");
200
+ const sessions = (await readPending(pendingPath))._unsafeUnwrap();
201
+ const branch = sessions[0].commits[0].branch;
202
+ expect(typeof branch).toBe("string");
203
+ expect(branch.length).toBeGreaterThan(0);
204
+ expect(branch).not.toBe("unknown");
205
+ });
206
+ });