buddy-reroll 0.2.0 → 0.3.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.
package/lib/hooks.js ADDED
@@ -0,0 +1,96 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const HOOK_COMMAND = "npx buddy-reroll --apply-hook";
6
+
7
+ export function getSettingsPath() {
8
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "settings.json");
9
+ }
10
+
11
+ export function readSettings(settingsPath) {
12
+ if (!existsSync(settingsPath)) {
13
+ return {};
14
+ }
15
+ try {
16
+ return JSON.parse(readFileSync(settingsPath, "utf-8"));
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ export function writeSettings(settingsPath, obj) {
23
+ const dir = dirname(settingsPath);
24
+ mkdirSync(dir, { recursive: true });
25
+ writeFileSync(settingsPath, JSON.stringify(obj, null, 2) + "\n");
26
+ }
27
+
28
+ export function installHook(settingsPath = getSettingsPath()) {
29
+ const settings = readSettings(settingsPath);
30
+
31
+ if (!settings.hooks) {
32
+ settings.hooks = {};
33
+ }
34
+ if (!settings.hooks.SessionStart) {
35
+ settings.hooks.SessionStart = [];
36
+ }
37
+
38
+ if (settings.hooks.SessionStart.includes(HOOK_COMMAND)) {
39
+ return { installed: false, reason: "already installed" };
40
+ }
41
+
42
+ settings.hooks.SessionStart.push(HOOK_COMMAND);
43
+ writeSettings(settingsPath, settings);
44
+
45
+ return { installed: true, path: settingsPath };
46
+ }
47
+
48
+ export function removeHook(settingsPath = getSettingsPath()) {
49
+ const settings = readSettings(settingsPath);
50
+
51
+ if (!settings.hooks || !settings.hooks.SessionStart) {
52
+ return { removed: false, reason: "not installed" };
53
+ }
54
+
55
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(cmd => cmd !== HOOK_COMMAND);
56
+
57
+ if (settings.hooks.SessionStart.length === 0) {
58
+ delete settings.hooks.SessionStart;
59
+ }
60
+
61
+ if (Object.keys(settings.hooks).length === 0) {
62
+ delete settings.hooks;
63
+ }
64
+
65
+ writeSettings(settingsPath, settings);
66
+
67
+ return { removed: true, path: settingsPath };
68
+ }
69
+
70
+ export function isHookInstalled(settingsPath = getSettingsPath()) {
71
+ const settings = readSettings(settingsPath);
72
+ return settings.hooks?.SessionStart?.includes(HOOK_COMMAND) ?? false;
73
+ }
74
+
75
+ export function getSaltStorePath() {
76
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), ".buddy-reroll.json");
77
+ }
78
+
79
+ export function storeSalt(salt) {
80
+ const path = getSaltStorePath();
81
+ const dir = dirname(path);
82
+ mkdirSync(dir, { recursive: true });
83
+ writeFileSync(path, JSON.stringify({ salt, timestamp: Date.now() }, null, 2) + "\n");
84
+ }
85
+
86
+ export function readStoredSalt() {
87
+ const path = getSaltStorePath();
88
+ if (!existsSync(path)) {
89
+ return null;
90
+ }
91
+ try {
92
+ return JSON.parse(readFileSync(path, "utf-8"));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
@@ -0,0 +1,240 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import {
6
+ readSettings,
7
+ writeSettings,
8
+ installHook,
9
+ removeHook,
10
+ isHookInstalled,
11
+ storeSalt,
12
+ readStoredSalt,
13
+ } from "./hooks.js";
14
+
15
+ let tempDir;
16
+
17
+ beforeEach(() => {
18
+ tempDir = mkdtempSync(join(tmpdir(), "buddy-reroll-test-"));
19
+ process.env.CLAUDE_CONFIG_DIR = tempDir;
20
+ });
21
+
22
+ afterEach(() => {
23
+ delete process.env.CLAUDE_CONFIG_DIR;
24
+ rmSync(tempDir, { recursive: true, force: true });
25
+ });
26
+
27
+ describe("readSettings", () => {
28
+ it("returns empty object when file doesn't exist", () => {
29
+ const settingsPath = join(tempDir, "settings.json");
30
+ expect(readSettings(settingsPath)).toEqual({});
31
+ });
32
+
33
+ it("parses valid JSON file", () => {
34
+ const settingsPath = join(tempDir, "settings.json");
35
+ writeSettings(settingsPath, { permissions: { allow: [] } });
36
+ expect(readSettings(settingsPath)).toEqual({ permissions: { allow: [] } });
37
+ });
38
+
39
+ it("returns empty object for corrupted JSON", () => {
40
+ const settingsPath = join(tempDir, "settings.json");
41
+ writeFileSync(settingsPath, "{ invalid json");
42
+ expect(readSettings(settingsPath)).toEqual({});
43
+ });
44
+ });
45
+
46
+ describe("writeSettings", () => {
47
+ it("creates parent directories if needed", () => {
48
+ const settingsPath = join(tempDir, "nested", "dir", "settings.json");
49
+ writeSettings(settingsPath, { test: true });
50
+ expect(readSettings(settingsPath)).toEqual({ test: true });
51
+ });
52
+
53
+ it("writes JSON with 2-space indent and newline", () => {
54
+ const settingsPath = join(tempDir, "settings.json");
55
+ writeSettings(settingsPath, { a: 1, b: 2 });
56
+ const content = readFileSync(settingsPath, "utf-8");
57
+ expect(content).toContain(" ");
58
+ expect(content.endsWith("\n")).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("installHook", () => {
63
+ it("creates settings.json if missing", () => {
64
+ const settingsPath = join(tempDir, "settings.json");
65
+ const result = installHook(settingsPath);
66
+ expect(result.installed).toBe(true);
67
+ const settings = readSettings(settingsPath);
68
+ expect(settings.hooks.SessionStart).toContain("npx buddy-reroll --apply-hook");
69
+ });
70
+
71
+ it("adds hook to existing settings", () => {
72
+ const settingsPath = join(tempDir, "settings.json");
73
+ writeSettings(settingsPath, { permissions: { allow: [] } });
74
+ installHook(settingsPath);
75
+ const settings = readSettings(settingsPath);
76
+ expect(settings.permissions).toEqual({ allow: [] });
77
+ expect(settings.hooks.SessionStart).toContain("npx buddy-reroll --apply-hook");
78
+ });
79
+
80
+ it("is idempotent - doesn't duplicate hook", () => {
81
+ const settingsPath = join(tempDir, "settings.json");
82
+ installHook(settingsPath);
83
+ const result = installHook(settingsPath);
84
+ expect(result.installed).toBe(false);
85
+ expect(result.reason).toBe("already installed");
86
+ const settings = readSettings(settingsPath);
87
+ const count = settings.hooks.SessionStart.filter(cmd => cmd === "npx buddy-reroll --apply-hook").length;
88
+ expect(count).toBe(1);
89
+ });
90
+
91
+ it("preserves existing hooks in SessionStart array", () => {
92
+ const settingsPath = join(tempDir, "settings.json");
93
+ writeSettings(settingsPath, {
94
+ hooks: { SessionStart: ["existing-hook"] },
95
+ });
96
+ installHook(settingsPath);
97
+ const settings = readSettings(settingsPath);
98
+ expect(settings.hooks.SessionStart).toContain("existing-hook");
99
+ expect(settings.hooks.SessionStart).toContain("npx buddy-reroll --apply-hook");
100
+ });
101
+ });
102
+
103
+ describe("removeHook", () => {
104
+ it("removes hook from SessionStart", () => {
105
+ const settingsPath = join(tempDir, "settings.json");
106
+ installHook(settingsPath);
107
+ const result = removeHook(settingsPath);
108
+ expect(result.removed).toBe(true);
109
+ const settings = readSettings(settingsPath);
110
+ expect(settings.hooks?.SessionStart?.includes("npx buddy-reroll --apply-hook") ?? false).toBe(false);
111
+ });
112
+
113
+ it("deletes empty SessionStart array", () => {
114
+ const settingsPath = join(tempDir, "settings.json");
115
+ installHook(settingsPath);
116
+ removeHook(settingsPath);
117
+ const settings = readSettings(settingsPath);
118
+ expect(settings.hooks?.SessionStart).toBeUndefined();
119
+ });
120
+
121
+ it("deletes empty hooks object", () => {
122
+ const settingsPath = join(tempDir, "settings.json");
123
+ installHook(settingsPath);
124
+ removeHook(settingsPath);
125
+ const settings = readSettings(settingsPath);
126
+ expect(settings.hooks).toBeUndefined();
127
+ });
128
+
129
+ it("returns false when hook not installed", () => {
130
+ const settingsPath = join(tempDir, "settings.json");
131
+ const result = removeHook(settingsPath);
132
+ expect(result.removed).toBe(false);
133
+ expect(result.reason).toBe("not installed");
134
+ });
135
+
136
+ it("preserves other hooks in SessionStart", () => {
137
+ const settingsPath = join(tempDir, "settings.json");
138
+ writeSettings(settingsPath, {
139
+ hooks: { SessionStart: ["other-hook", "npx buddy-reroll --apply-hook"] },
140
+ });
141
+ removeHook(settingsPath);
142
+ const settings = readSettings(settingsPath);
143
+ expect(settings.hooks.SessionStart).toContain("other-hook");
144
+ expect(settings.hooks.SessionStart).not.toContain("npx buddy-reroll --apply-hook");
145
+ });
146
+
147
+ it("preserves other hook types", () => {
148
+ const settingsPath = join(tempDir, "settings.json");
149
+ writeSettings(settingsPath, {
150
+ hooks: {
151
+ SessionStart: ["npx buddy-reroll --apply-hook"],
152
+ OnExit: ["some-command"],
153
+ },
154
+ });
155
+ removeHook(settingsPath);
156
+ const settings = readSettings(settingsPath);
157
+ expect(settings.hooks.OnExit).toContain("some-command");
158
+ });
159
+ });
160
+
161
+ describe("isHookInstalled", () => {
162
+ it("returns true after install", () => {
163
+ const settingsPath = join(tempDir, "settings.json");
164
+ installHook(settingsPath);
165
+ expect(isHookInstalled(settingsPath)).toBe(true);
166
+ });
167
+
168
+ it("returns false after remove", () => {
169
+ const settingsPath = join(tempDir, "settings.json");
170
+ installHook(settingsPath);
171
+ removeHook(settingsPath);
172
+ expect(isHookInstalled(settingsPath)).toBe(false);
173
+ });
174
+
175
+ it("returns false when settings don't exist", () => {
176
+ const settingsPath = join(tempDir, "settings.json");
177
+ expect(isHookInstalled(settingsPath)).toBe(false);
178
+ });
179
+
180
+ it("returns false when hooks don't exist", () => {
181
+ const settingsPath = join(tempDir, "settings.json");
182
+ writeSettings(settingsPath, { permissions: { allow: [] } });
183
+ expect(isHookInstalled(settingsPath)).toBe(false);
184
+ });
185
+ });
186
+
187
+ describe("storeSalt and readStoredSalt", () => {
188
+ it("roundtrips salt with timestamp", () => {
189
+ const salt = "test-salt-value";
190
+ storeSalt(salt);
191
+ const stored = readStoredSalt();
192
+ expect(stored.salt).toBe(salt);
193
+ expect(typeof stored.timestamp).toBe("number");
194
+ expect(stored.timestamp).toBeGreaterThan(0);
195
+ });
196
+
197
+ it("returns null when file doesn't exist", () => {
198
+ expect(readStoredSalt()).toBeNull();
199
+ });
200
+
201
+ it("returns null for corrupted salt file", () => {
202
+ const saltPath = join(tempDir, ".buddy-reroll.json");
203
+ writeFileSync(saltPath, "{ invalid json");
204
+ expect(readStoredSalt()).toBeNull();
205
+ });
206
+
207
+ it("creates parent directories for salt file", () => {
208
+ const salt = "another-test-salt";
209
+ storeSalt(salt);
210
+ const stored = readStoredSalt();
211
+ expect(stored.salt).toBe(salt);
212
+ });
213
+ });
214
+
215
+ describe("integration", () => {
216
+ it("preserves existing settings when installing hook", () => {
217
+ const settingsPath = join(tempDir, "settings.json");
218
+ writeSettings(settingsPath, {
219
+ permissions: { allow: ["some-permission"] },
220
+ other: { nested: { value: 42 } },
221
+ });
222
+ installHook(settingsPath);
223
+ const settings = readSettings(settingsPath);
224
+ expect(settings.permissions).toEqual({ allow: ["some-permission"] });
225
+ expect(settings.other).toEqual({ nested: { value: 42 } });
226
+ expect(settings.hooks.SessionStart).toContain("npx buddy-reroll --apply-hook");
227
+ });
228
+
229
+ it("preserves existing settings when removing hook", () => {
230
+ const settingsPath = join(tempDir, "settings.json");
231
+ writeSettings(settingsPath, {
232
+ permissions: { allow: ["some-permission"] },
233
+ hooks: { SessionStart: ["npx buddy-reroll --apply-hook"] },
234
+ });
235
+ removeHook(settingsPath);
236
+ const settings = readSettings(settingsPath);
237
+ expect(settings.permissions).toEqual({ allow: ["some-permission"] });
238
+ expect(settings.hooks).toBeUndefined();
239
+ });
240
+ });
package/lib/runtime.js ADDED
@@ -0,0 +1,184 @@
1
+ import { accessSync, constants, existsSync, readFileSync, readdirSync, realpathSync, statSync } from "fs";
2
+ import { execFileSync } from "child_process";
3
+ import { join, dirname, isAbsolute } from "path";
4
+ import { homedir, platform } from "os";
5
+
6
+ const MIN_BINARY_SIZE = 1_000_000;
7
+ const MAX_WRAPPER_SIZE = 64 * 1024;
8
+ const MAX_RESOLVE_DEPTH = 4;
9
+ const SHELL_EXEC_PATTERN = /^\s*exec\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))(?:\s|$)/m;
10
+
11
+ function isRealBinary(candidatePath) {
12
+ try {
13
+ const stats = statSync(candidatePath);
14
+ return stats.isFile() && stats.size > MIN_BINARY_SIZE;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function getClaudeConfigDir() {
21
+ return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
22
+ }
23
+
24
+ export function getClaudeBinaryOverride() {
25
+ return process.env.CLAUDE_BINARY_PATH?.trim() || null;
26
+ }
27
+
28
+ function expandShellPath(pathExpression) {
29
+ if (!pathExpression) return null;
30
+
31
+ const expanded = pathExpression
32
+ .replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? "")
33
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => process.env[name] ?? "");
34
+
35
+ if (expanded === "~") return homedir();
36
+ if (expanded.startsWith("~/")) return join(homedir(), expanded.slice(2));
37
+ return isAbsolute(expanded) ? expanded : null;
38
+ }
39
+
40
+ export function resolveClaudeExecutable(candidatePath, depth = 0, visited = new Set()) {
41
+ if (!candidatePath || depth > MAX_RESOLVE_DEPTH) return null;
42
+
43
+ const trimmedPath = candidatePath.trim();
44
+ if (!trimmedPath) return null;
45
+
46
+ let resolvedPath;
47
+ try {
48
+ resolvedPath = realpathSync(trimmedPath);
49
+ } catch {
50
+ resolvedPath = trimmedPath;
51
+ }
52
+
53
+ if (visited.has(resolvedPath)) return null;
54
+ visited.add(resolvedPath);
55
+
56
+ if (!existsSync(resolvedPath)) return null;
57
+ if (isRealBinary(resolvedPath)) return resolvedPath;
58
+
59
+ try {
60
+ const stats = statSync(resolvedPath);
61
+ if (!stats.isFile() || stats.size > MAX_WRAPPER_SIZE) return null;
62
+
63
+ const script = readFileSync(resolvedPath, "utf-8");
64
+ const execMatch = script.match(SHELL_EXEC_PATTERN);
65
+ if (!execMatch) return null;
66
+
67
+ const execTarget = expandShellPath(execMatch[1] ?? execMatch[2] ?? execMatch[3]);
68
+ if (!execTarget) return null;
69
+
70
+ return resolveClaudeExecutable(execTarget, depth + 1, visited);
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function getPathCandidates() {
77
+ const isWin = platform() === "win32";
78
+ const override = getClaudeBinaryOverride();
79
+
80
+ if (override) return [override];
81
+
82
+ try {
83
+ const command = isWin ? "where.exe" : "which";
84
+ const args = isWin ? ["claude"] : ["-a", "claude"];
85
+ return execFileSync(command, args, {
86
+ encoding: "utf-8",
87
+ stdio: ["ignore", "pipe", "ignore"],
88
+ })
89
+ .split("\n")
90
+ .map((entry) => entry.trim())
91
+ .filter(Boolean);
92
+ } catch {
93
+ return [];
94
+ }
95
+ }
96
+
97
+ function getVersionCandidates() {
98
+ const isWin = platform() === "win32";
99
+ const versionsDirs = [
100
+ join(homedir(), ".local", "share", "claude", "versions"),
101
+ ...(isWin ? [join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "Claude", "versions")] : []),
102
+ ];
103
+
104
+ const candidates = [];
105
+ for (const entry of versionsDirs) {
106
+ if (!existsSync(entry)) continue;
107
+
108
+ try {
109
+ const stats = statSync(entry);
110
+ if (stats.isFile()) {
111
+ candidates.push(entry);
112
+ continue;
113
+ }
114
+
115
+ const versions = readdirSync(entry)
116
+ .filter((file) => !file.includes(".backup"))
117
+ .sort();
118
+
119
+ if (versions.length > 0) {
120
+ candidates.push(join(entry, versions[versions.length - 1]));
121
+ }
122
+ } catch {}
123
+ }
124
+
125
+ return candidates;
126
+ }
127
+
128
+ export function findBinaryPath() {
129
+ const seen = new Set();
130
+ const candidates = [...getPathCandidates(), ...getVersionCandidates()];
131
+
132
+ for (const candidate of candidates) {
133
+ const resolved = resolveClaudeExecutable(candidate);
134
+ if (resolved && !seen.has(resolved)) {
135
+ seen.add(resolved);
136
+ return resolved;
137
+ }
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ export function findConfigPath() {
144
+ const claudeDir = getClaudeConfigDir();
145
+
146
+ const legacyPath = join(claudeDir, ".config.json");
147
+ if (existsSync(legacyPath)) return legacyPath;
148
+
149
+ const defaultPath = join(homedir(), ".claude.json");
150
+ if (existsSync(defaultPath)) return defaultPath;
151
+
152
+ if (platform() === "win32" && process.env.APPDATA) {
153
+ const appDataPath = join(process.env.APPDATA, "Claude", "config.json");
154
+ if (existsSync(appDataPath)) return appDataPath;
155
+ }
156
+
157
+ return null;
158
+ }
159
+
160
+ export function getPatchability(binaryPath) {
161
+ if (!binaryPath) {
162
+ return { ok: false, message: "Claude Code binary path is missing.", backupPath: null };
163
+ }
164
+
165
+ if (!existsSync(binaryPath)) {
166
+ return { ok: false, message: `Claude Code binary was not found at ${binaryPath}.`, backupPath: null };
167
+ }
168
+
169
+ const backupPath = `${binaryPath}.backup`;
170
+
171
+ try {
172
+ accessSync(binaryPath, constants.W_OK);
173
+ if (!existsSync(backupPath)) {
174
+ accessSync(dirname(binaryPath), constants.W_OK);
175
+ }
176
+ return { ok: true, message: null, backupPath };
177
+ } catch {
178
+ return {
179
+ ok: false,
180
+ message: `Claude install found at ${binaryPath}, but it is not writable by the current user. buddy-reroll can show the current companion, but reroll and restore require a user-writable Claude install.`,
181
+ backupPath,
182
+ };
183
+ }
184
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { resolveClaudeExecutable, getClaudeBinaryOverride, getClaudeConfigDir, getPatchability } from "./runtime.js";
3
+ import { writeFileSync, mkdirSync, rmSync, chmodSync, realpathSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+
7
+ function makeTempDir() {
8
+ const dir = join(realpathSync(tmpdir()), `buddy-reroll-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
9
+ mkdirSync(dir, { recursive: true });
10
+ return dir;
11
+ }
12
+
13
+ describe("getClaudeConfigDir", () => {
14
+ it("returns ~/.claude by default", () => {
15
+ const original = process.env.CLAUDE_CONFIG_DIR;
16
+ delete process.env.CLAUDE_CONFIG_DIR;
17
+ const result = getClaudeConfigDir();
18
+ expect(result).toMatch(/\.claude$/);
19
+ if (original) process.env.CLAUDE_CONFIG_DIR = original;
20
+ });
21
+
22
+ it("respects CLAUDE_CONFIG_DIR env var", () => {
23
+ const original = process.env.CLAUDE_CONFIG_DIR;
24
+ process.env.CLAUDE_CONFIG_DIR = "/custom/path";
25
+ expect(getClaudeConfigDir()).toBe("/custom/path");
26
+ if (original) process.env.CLAUDE_CONFIG_DIR = original;
27
+ else delete process.env.CLAUDE_CONFIG_DIR;
28
+ });
29
+ });
30
+
31
+ describe("getClaudeBinaryOverride", () => {
32
+ it("returns null when not set", () => {
33
+ const original = process.env.CLAUDE_BINARY_PATH;
34
+ delete process.env.CLAUDE_BINARY_PATH;
35
+ expect(getClaudeBinaryOverride()).toBeNull();
36
+ if (original) process.env.CLAUDE_BINARY_PATH = original;
37
+ });
38
+
39
+ it("returns trimmed path when set", () => {
40
+ const original = process.env.CLAUDE_BINARY_PATH;
41
+ process.env.CLAUDE_BINARY_PATH = " /some/path ";
42
+ expect(getClaudeBinaryOverride()).toBe("/some/path");
43
+ if (original) process.env.CLAUDE_BINARY_PATH = original;
44
+ else delete process.env.CLAUDE_BINARY_PATH;
45
+ });
46
+ });
47
+
48
+ describe("resolveClaudeExecutable", () => {
49
+ it("returns null for non-existent path", () => {
50
+ expect(resolveClaudeExecutable("/nonexistent/path")).toBeNull();
51
+ });
52
+
53
+ it("returns null for empty input", () => {
54
+ expect(resolveClaudeExecutable("")).toBeNull();
55
+ expect(resolveClaudeExecutable(null)).toBeNull();
56
+ });
57
+
58
+ it("returns path for large files (real binary)", () => {
59
+ const dir = makeTempDir();
60
+ const fakeBinary = join(dir, "claude");
61
+ writeFileSync(fakeBinary, Buffer.alloc(2_000_000));
62
+ expect(resolveClaudeExecutable(fakeBinary)).toBe(fakeBinary);
63
+ rmSync(dir, { recursive: true });
64
+ });
65
+
66
+ it("follows shell exec wrapper to real binary", () => {
67
+ const dir = makeTempDir();
68
+ const realBinary = join(dir, "claude-real");
69
+ const wrapper = join(dir, "claude");
70
+ writeFileSync(realBinary, Buffer.alloc(2_000_000));
71
+ writeFileSync(wrapper, `#!/bin/sh\nexec "${realBinary}" "$@"\n`);
72
+ chmodSync(wrapper, 0o755);
73
+ expect(resolveClaudeExecutable(wrapper)).toBe(realBinary);
74
+ rmSync(dir, { recursive: true });
75
+ });
76
+
77
+ it("prevents infinite loops via cycle detection", () => {
78
+ const dir = makeTempDir();
79
+ const a = join(dir, "a");
80
+ const b = join(dir, "b");
81
+ writeFileSync(a, `#!/bin/sh\nexec "${b}" "$@"\n`);
82
+ writeFileSync(b, `#!/bin/sh\nexec "${a}" "$@"\n`);
83
+ chmodSync(a, 0o755);
84
+ chmodSync(b, 0o755);
85
+ expect(resolveClaudeExecutable(a)).toBeNull();
86
+ rmSync(dir, { recursive: true });
87
+ });
88
+ });
89
+
90
+ describe("getPatchability", () => {
91
+ it("returns ok for writable file", () => {
92
+ const dir = makeTempDir();
93
+ const file = join(dir, "claude");
94
+ writeFileSync(file, "data");
95
+ const result = getPatchability(file);
96
+ expect(result.ok).toBe(true);
97
+ expect(result.backupPath).toBe(`${file}.backup`);
98
+ rmSync(dir, { recursive: true });
99
+ });
100
+
101
+ it("returns not ok for missing path", () => {
102
+ const result = getPatchability(null);
103
+ expect(result.ok).toBe(false);
104
+ });
105
+
106
+ it("returns not ok for non-existent file", () => {
107
+ const result = getPatchability("/nonexistent/file");
108
+ expect(result.ok).toBe(false);
109
+ });
110
+ });