ashlrcode 1.0.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/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- package/src/voice/voice-mode.ts +169 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { categorizeError, retryWithBackoff } from "../agent/error-handler.ts";
|
|
3
|
+
|
|
4
|
+
describe("categorizeError", () => {
|
|
5
|
+
describe("rate_limit", () => {
|
|
6
|
+
test("detects 429 status code", () => {
|
|
7
|
+
const result = categorizeError(new Error("HTTP 429 Too Many Requests"));
|
|
8
|
+
expect(result.category).toBe("rate_limit");
|
|
9
|
+
expect(result.retryable).toBe(true);
|
|
10
|
+
expect(result.retryAfterMs).toBeGreaterThan(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("detects rate_limit keyword", () => {
|
|
14
|
+
const result = categorizeError("rate_limit exceeded");
|
|
15
|
+
expect(result.category).toBe("rate_limit");
|
|
16
|
+
expect(result.retryable).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("detects quota keyword", () => {
|
|
20
|
+
const result = categorizeError(new Error("quota exceeded for this key"));
|
|
21
|
+
expect(result.category).toBe("rate_limit");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("detects 'too many requests'", () => {
|
|
25
|
+
const result = categorizeError("Too many requests, please slow down");
|
|
26
|
+
expect(result.category).toBe("rate_limit");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("extracts retry-after from message", () => {
|
|
30
|
+
const result = categorizeError(new Error("429 rate_limit: retry after 30 seconds"));
|
|
31
|
+
expect(result.category).toBe("rate_limit");
|
|
32
|
+
expect(result.retryAfterMs).toBe(30000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("defaults to 5000ms when no retry-after found", () => {
|
|
36
|
+
const result = categorizeError(new Error("429"));
|
|
37
|
+
expect(result.retryAfterMs).toBe(5000);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("auth", () => {
|
|
42
|
+
test("detects 401 status", () => {
|
|
43
|
+
const result = categorizeError(new Error("HTTP 401 Unauthorized"));
|
|
44
|
+
expect(result.category).toBe("auth");
|
|
45
|
+
expect(result.retryable).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("detects 403 status", () => {
|
|
49
|
+
const result = categorizeError(new Error("403 Forbidden"));
|
|
50
|
+
expect(result.category).toBe("auth");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("detects 'unauthorized'", () => {
|
|
54
|
+
const result = categorizeError("Request unauthorized");
|
|
55
|
+
expect(result.category).toBe("auth");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("detects 'invalid api key'", () => {
|
|
59
|
+
const result = categorizeError(new Error("Invalid API key provided"));
|
|
60
|
+
expect(result.category).toBe("auth");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("network", () => {
|
|
65
|
+
test("detects ECONNREFUSED", () => {
|
|
66
|
+
const result = categorizeError(new Error("connect ECONNREFUSED 127.0.0.1:443"));
|
|
67
|
+
expect(result.category).toBe("network");
|
|
68
|
+
expect(result.retryable).toBe(true);
|
|
69
|
+
expect(result.retryAfterMs).toBe(2000);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("detects ENOTFOUND", () => {
|
|
73
|
+
const result = categorizeError(new Error("getaddrinfo ENOTFOUND api.example.com"));
|
|
74
|
+
expect(result.category).toBe("network");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("detects timeout", () => {
|
|
78
|
+
const result = categorizeError("Request timeout after 30s");
|
|
79
|
+
expect(result.category).toBe("network");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("detects fetch failed", () => {
|
|
83
|
+
const result = categorizeError(new Error("fetch failed"));
|
|
84
|
+
expect(result.category).toBe("network");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("detects socket errors", () => {
|
|
88
|
+
const result = categorizeError(new Error("socket hang up"));
|
|
89
|
+
expect(result.category).toBe("network");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("validation", () => {
|
|
94
|
+
test("detects validation keyword", () => {
|
|
95
|
+
const result = categorizeError(new Error("Validation error: missing field"));
|
|
96
|
+
expect(result.category).toBe("validation");
|
|
97
|
+
expect(result.retryable).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("detects invalid keyword", () => {
|
|
101
|
+
const result = categorizeError("Invalid parameter: model");
|
|
102
|
+
expect(result.category).toBe("validation");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("detects schema keyword", () => {
|
|
106
|
+
const result = categorizeError(new Error("Schema mismatch on input"));
|
|
107
|
+
expect(result.category).toBe("validation");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("unknown", () => {
|
|
112
|
+
test("returns unknown for unrecognized errors", () => {
|
|
113
|
+
const result = categorizeError(new Error("Something unexpected happened"));
|
|
114
|
+
expect(result.category).toBe("unknown");
|
|
115
|
+
expect(result.retryable).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("handles string errors", () => {
|
|
119
|
+
const result = categorizeError("just a string error");
|
|
120
|
+
expect(result.category).toBe("unknown");
|
|
121
|
+
expect(result.message).toBe("just a string error");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("priority: rate_limit before auth", () => {
|
|
126
|
+
// If a message contains both "429" and "unauthorized", rate_limit should win
|
|
127
|
+
// because it's checked first
|
|
128
|
+
test("rate_limit takes priority when both match", () => {
|
|
129
|
+
const result = categorizeError(new Error("429 unauthorized"));
|
|
130
|
+
expect(result.category).toBe("rate_limit");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("retryWithBackoff", () => {
|
|
136
|
+
test("returns result on first success", async () => {
|
|
137
|
+
const result = await retryWithBackoff(async () => "success", 3, 10);
|
|
138
|
+
expect(result).toBe("success");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("retries on retryable error and succeeds", async () => {
|
|
142
|
+
let attempts = 0;
|
|
143
|
+
const result = await retryWithBackoff(
|
|
144
|
+
async () => {
|
|
145
|
+
attempts++;
|
|
146
|
+
if (attempts < 3) throw new Error("fetch failed");
|
|
147
|
+
return "recovered";
|
|
148
|
+
},
|
|
149
|
+
3,
|
|
150
|
+
10 // Short delay for test speed
|
|
151
|
+
);
|
|
152
|
+
expect(result).toBe("recovered");
|
|
153
|
+
expect(attempts).toBe(3);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("throws on non-retryable error immediately", async () => {
|
|
157
|
+
let attempts = 0;
|
|
158
|
+
try {
|
|
159
|
+
await retryWithBackoff(
|
|
160
|
+
async () => {
|
|
161
|
+
attempts++;
|
|
162
|
+
throw new Error("401 Unauthorized");
|
|
163
|
+
},
|
|
164
|
+
3,
|
|
165
|
+
10
|
|
166
|
+
);
|
|
167
|
+
expect(true).toBe(false); // Should not reach here
|
|
168
|
+
} catch (err) {
|
|
169
|
+
expect((err as Error).message).toContain("401");
|
|
170
|
+
}
|
|
171
|
+
expect(attempts).toBe(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("throws after max retries exhausted", async () => {
|
|
175
|
+
let attempts = 0;
|
|
176
|
+
try {
|
|
177
|
+
await retryWithBackoff(
|
|
178
|
+
async () => {
|
|
179
|
+
attempts++;
|
|
180
|
+
throw new Error("fetch failed"); // network = retryable
|
|
181
|
+
},
|
|
182
|
+
2,
|
|
183
|
+
10
|
|
184
|
+
);
|
|
185
|
+
expect(true).toBe(false);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
expect((err as Error).message).toContain("fetch failed");
|
|
188
|
+
}
|
|
189
|
+
// 1 initial + 2 retries = 3 attempts
|
|
190
|
+
expect(attempts).toBe(3);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { test, expect, describe, afterEach } from "bun:test";
|
|
2
|
+
import { feature, setFeature, listFeatures } from "../config/features.ts";
|
|
3
|
+
|
|
4
|
+
// Track features we modify so we can reset them
|
|
5
|
+
const modified: Array<{ name: string; original: boolean }> = [];
|
|
6
|
+
|
|
7
|
+
function setAndTrack(name: string, value: boolean) {
|
|
8
|
+
const features = listFeatures();
|
|
9
|
+
if (name in features) {
|
|
10
|
+
modified.push({ name, original: features[name]! });
|
|
11
|
+
} else {
|
|
12
|
+
modified.push({ name, original: false });
|
|
13
|
+
}
|
|
14
|
+
setFeature(name, value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { name, original } of modified) {
|
|
19
|
+
setFeature(name, original);
|
|
20
|
+
}
|
|
21
|
+
modified.length = 0;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("feature", () => {
|
|
25
|
+
test("returns default values for known flags", () => {
|
|
26
|
+
const features = listFeatures();
|
|
27
|
+
// DREAM_TASK defaults to true
|
|
28
|
+
expect(feature("DREAM_TASK")).toBe(features["DREAM_TASK"]!);
|
|
29
|
+
// VOICE_MODE defaults to false
|
|
30
|
+
expect(feature("VOICE_MODE")).toBe(features["VOICE_MODE"]!);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns false for unknown feature", () => {
|
|
34
|
+
expect(feature("TOTALLY_FAKE_FEATURE_" + Date.now())).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("setFeature", () => {
|
|
39
|
+
test("overrides a feature value", () => {
|
|
40
|
+
setAndTrack("VOICE_MODE", true);
|
|
41
|
+
expect(feature("VOICE_MODE")).toBe(true);
|
|
42
|
+
|
|
43
|
+
setAndTrack("VOICE_MODE", false);
|
|
44
|
+
expect(feature("VOICE_MODE")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("can set a new feature that did not exist", () => {
|
|
48
|
+
const name = "TEST_FEATURE_" + Date.now();
|
|
49
|
+
setAndTrack(name, true);
|
|
50
|
+
expect(feature(name)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("listFeatures", () => {
|
|
55
|
+
test("returns all flags as a plain object", () => {
|
|
56
|
+
const features = listFeatures();
|
|
57
|
+
expect(typeof features).toBe("object");
|
|
58
|
+
expect("DREAM_TASK" in features).toBe(true);
|
|
59
|
+
expect("VOICE_MODE" in features).toBe(true);
|
|
60
|
+
expect("TEAM_MODE" in features).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns a copy (mutations do not affect internal state)", () => {
|
|
64
|
+
const features = listFeatures();
|
|
65
|
+
features["DREAM_TASK"] = false;
|
|
66
|
+
// Internal state should be unchanged
|
|
67
|
+
expect(feature("DREAM_TASK")).toBe(listFeatures()["DREAM_TASK"]!);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { FileHistoryStore, setFileHistory, getFileHistory, fileHistory } from "../state/file-history.ts";
|
|
3
|
+
import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
|
|
7
|
+
describe("FileHistoryStore", () => {
|
|
8
|
+
const testDir = join(tmpdir(), `ashlrcode-test-${Date.now()}`);
|
|
9
|
+
const testFile = join(testDir, "test.txt");
|
|
10
|
+
let store: FileHistoryStore;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
store = new FileHistoryStore(`test-${Date.now()}`);
|
|
14
|
+
setFileHistory(store);
|
|
15
|
+
mkdirSync(testDir, { recursive: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (existsSync(testDir)) {
|
|
20
|
+
rmSync(testDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("capture records existing file content", async () => {
|
|
25
|
+
writeFileSync(testFile, "original content");
|
|
26
|
+
await store.capture(testFile, "Write", 1);
|
|
27
|
+
expect(store.hasSnapshot(testFile)).toBe(true);
|
|
28
|
+
expect(store.undoCount).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("capture records new file as empty content (undo = delete)", async () => {
|
|
32
|
+
const newFile = join(testDir, "brand-new.txt");
|
|
33
|
+
await store.capture(newFile, "Write", 1);
|
|
34
|
+
expect(store.hasSnapshot(newFile)).toBe(true);
|
|
35
|
+
expect(store.undoCount).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("undoLast restores previous content", async () => {
|
|
39
|
+
writeFileSync(testFile, "version 1");
|
|
40
|
+
await store.capture(testFile, "Edit", 1);
|
|
41
|
+
|
|
42
|
+
writeFileSync(testFile, "version 2");
|
|
43
|
+
expect(readFileSync(testFile, "utf-8")).toBe("version 2");
|
|
44
|
+
|
|
45
|
+
const result = await store.undoLast();
|
|
46
|
+
expect(result).not.toBeNull();
|
|
47
|
+
expect(result!.filePath).toBe(testFile);
|
|
48
|
+
expect(result!.restored).toBe(true);
|
|
49
|
+
expect(readFileSync(testFile, "utf-8")).toBe("version 1");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("undoLast deletes newly created file", async () => {
|
|
53
|
+
const newFile = join(testDir, "created.txt");
|
|
54
|
+
await store.capture(newFile, "Write", 1);
|
|
55
|
+
|
|
56
|
+
writeFileSync(newFile, "new content");
|
|
57
|
+
expect(existsSync(newFile)).toBe(true);
|
|
58
|
+
|
|
59
|
+
const result = await store.undoLast();
|
|
60
|
+
expect(result).not.toBeNull();
|
|
61
|
+
expect(existsSync(newFile)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("undoLast returns null when no snapshots", async () => {
|
|
65
|
+
const result = await store.undoLast();
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("multiple snapshots form a stack (LIFO)", async () => {
|
|
70
|
+
writeFileSync(testFile, "v1");
|
|
71
|
+
await store.capture(testFile, "Edit", 1);
|
|
72
|
+
|
|
73
|
+
writeFileSync(testFile, "v2");
|
|
74
|
+
await store.capture(testFile, "Edit", 2);
|
|
75
|
+
|
|
76
|
+
writeFileSync(testFile, "v3");
|
|
77
|
+
|
|
78
|
+
// First undo → v2
|
|
79
|
+
await store.undoLast();
|
|
80
|
+
expect(readFileSync(testFile, "utf-8")).toBe("v2");
|
|
81
|
+
|
|
82
|
+
// Second undo → v1
|
|
83
|
+
await store.undoLast();
|
|
84
|
+
expect(readFileSync(testFile, "utf-8")).toBe("v1");
|
|
85
|
+
|
|
86
|
+
expect(store.undoCount).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("undoTurn restores all changes from a specific turn", async () => {
|
|
90
|
+
const file2 = join(testDir, "other.txt");
|
|
91
|
+
writeFileSync(testFile, "a-original");
|
|
92
|
+
writeFileSync(file2, "b-original");
|
|
93
|
+
|
|
94
|
+
await store.capture(testFile, "Edit", 3);
|
|
95
|
+
await store.capture(file2, "Edit", 3);
|
|
96
|
+
|
|
97
|
+
writeFileSync(testFile, "a-modified");
|
|
98
|
+
writeFileSync(file2, "b-modified");
|
|
99
|
+
|
|
100
|
+
const restored = await store.undoTurn(3);
|
|
101
|
+
expect(restored).toContain(testFile);
|
|
102
|
+
expect(restored).toContain(file2);
|
|
103
|
+
expect(readFileSync(testFile, "utf-8")).toBe("a-original");
|
|
104
|
+
expect(readFileSync(file2, "utf-8")).toBe("b-original");
|
|
105
|
+
expect(store.undoCount).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("undoTurn only affects the specified turn", async () => {
|
|
109
|
+
writeFileSync(testFile, "turn1-before");
|
|
110
|
+
await store.capture(testFile, "Edit", 1);
|
|
111
|
+
writeFileSync(testFile, "turn1-after");
|
|
112
|
+
|
|
113
|
+
await store.capture(testFile, "Edit", 2);
|
|
114
|
+
writeFileSync(testFile, "turn2-after");
|
|
115
|
+
|
|
116
|
+
// Undo only turn 2
|
|
117
|
+
await store.undoTurn(2);
|
|
118
|
+
expect(readFileSync(testFile, "utf-8")).toBe("turn1-after");
|
|
119
|
+
expect(store.undoCount).toBe(1); // turn 1 snapshot still there
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("getSnapshotFiles returns files with snapshots", async () => {
|
|
123
|
+
const file2 = join(testDir, "other.txt");
|
|
124
|
+
writeFileSync(testFile, "content1");
|
|
125
|
+
writeFileSync(file2, "content2");
|
|
126
|
+
|
|
127
|
+
await store.capture(testFile, "Write", 1);
|
|
128
|
+
await store.capture(file2, "Edit", 1);
|
|
129
|
+
|
|
130
|
+
const files = store.getSnapshotFiles();
|
|
131
|
+
expect(files.length).toBe(2);
|
|
132
|
+
|
|
133
|
+
const paths = files.map((f) => f.path);
|
|
134
|
+
expect(paths).toContain(testFile);
|
|
135
|
+
expect(paths).toContain(file2);
|
|
136
|
+
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
expect(f.count).toBe(1);
|
|
139
|
+
expect(f.lastModified).toBeTruthy();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("getHistory returns snapshots newest first", async () => {
|
|
144
|
+
writeFileSync(testFile, "v1");
|
|
145
|
+
await store.capture(testFile, "Edit", 1);
|
|
146
|
+
writeFileSync(testFile, "v2");
|
|
147
|
+
await store.capture(testFile, "Edit", 2);
|
|
148
|
+
|
|
149
|
+
const history = store.getHistory();
|
|
150
|
+
expect(history.length).toBe(2);
|
|
151
|
+
expect(history[0]!.turnNumber).toBe(2); // newest first
|
|
152
|
+
expect(history[1]!.turnNumber).toBe(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("clear removes all snapshots", async () => {
|
|
156
|
+
writeFileSync(testFile, "content");
|
|
157
|
+
await store.capture(testFile, "Write", 1);
|
|
158
|
+
expect(store.hasSnapshot(testFile)).toBe(true);
|
|
159
|
+
|
|
160
|
+
store.clear();
|
|
161
|
+
expect(store.hasSnapshot(testFile)).toBe(false);
|
|
162
|
+
expect(store.getSnapshotFiles()).toEqual([]);
|
|
163
|
+
expect(store.undoCount).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Backward-compat shim tests
|
|
167
|
+
test("fileHistory shim delegates to the active store", async () => {
|
|
168
|
+
writeFileSync(testFile, "original");
|
|
169
|
+
await fileHistory.snapshot(testFile);
|
|
170
|
+
expect(fileHistory.hasSnapshot(testFile)).toBe(true);
|
|
171
|
+
|
|
172
|
+
writeFileSync(testFile, "changed");
|
|
173
|
+
const restored = await fileHistory.restore(testFile);
|
|
174
|
+
expect(restored).toBe(true);
|
|
175
|
+
expect(readFileSync(testFile, "utf-8")).toBe("original");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { runPreToolHooks, runPostToolHooks } from "../config/hooks.ts";
|
|
3
|
+
import type { HooksConfig, HookDefinition } from "../config/hooks.ts";
|
|
4
|
+
|
|
5
|
+
describe("runPreToolHooks", () => {
|
|
6
|
+
test("returns allow when no hooks are configured", async () => {
|
|
7
|
+
const result = await runPreToolHooks({}, "Bash", { command: "ls" });
|
|
8
|
+
expect(result.action).toBe("allow");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("returns allow when no hooks match", async () => {
|
|
12
|
+
const hooks: HooksConfig = {
|
|
13
|
+
preToolUse: [{ toolName: "Write", action: "deny" }],
|
|
14
|
+
};
|
|
15
|
+
const result = await runPreToolHooks(hooks, "Bash", {});
|
|
16
|
+
expect(result.action).toBe("allow");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("denies when exact toolName matches deny hook", async () => {
|
|
20
|
+
const hooks: HooksConfig = {
|
|
21
|
+
preToolUse: [{ toolName: "Bash", action: "deny", message: "no bash" }],
|
|
22
|
+
};
|
|
23
|
+
const result = await runPreToolHooks(hooks, "Bash", {});
|
|
24
|
+
expect(result.action).toBe("deny");
|
|
25
|
+
expect(result.message).toBe("no bash");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("allows when exact toolName matches allow hook", async () => {
|
|
29
|
+
const hooks: HooksConfig = {
|
|
30
|
+
preToolUse: [{ toolName: "Read", action: "allow" }],
|
|
31
|
+
};
|
|
32
|
+
const result = await runPreToolHooks(hooks, "Read", {});
|
|
33
|
+
expect(result.action).toBe("allow");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("matches glob patterns in toolName", async () => {
|
|
37
|
+
const hooks: HooksConfig = {
|
|
38
|
+
preToolUse: [{ toolName: "Bash*", action: "deny", message: "no bash" }],
|
|
39
|
+
};
|
|
40
|
+
const result = await runPreToolHooks(hooks, "BashTool", {});
|
|
41
|
+
expect(result.action).toBe("deny");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("matches wildcard * to any tool", async () => {
|
|
45
|
+
const hooks: HooksConfig = {
|
|
46
|
+
preToolUse: [{ toolName: "*", action: "deny", message: "all blocked" }],
|
|
47
|
+
};
|
|
48
|
+
const result = await runPreToolHooks(hooks, "AnyTool", {});
|
|
49
|
+
expect(result.action).toBe("deny");
|
|
50
|
+
expect(result.message).toBe("all blocked");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("matches inputPattern against serialized input", async () => {
|
|
54
|
+
const hooks: HooksConfig = {
|
|
55
|
+
preToolUse: [
|
|
56
|
+
{
|
|
57
|
+
inputPattern: "rm\\s+-rf",
|
|
58
|
+
action: "deny",
|
|
59
|
+
message: "dangerous command",
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
const result = await runPreToolHooks(hooks, "Bash", {
|
|
64
|
+
command: "rm -rf /",
|
|
65
|
+
});
|
|
66
|
+
expect(result.action).toBe("deny");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("does not match inputPattern when pattern is absent from input", async () => {
|
|
70
|
+
const hooks: HooksConfig = {
|
|
71
|
+
preToolUse: [{ inputPattern: "rm\\s+-rf", action: "deny" }],
|
|
72
|
+
};
|
|
73
|
+
const result = await runPreToolHooks(hooks, "Bash", { command: "ls -la" });
|
|
74
|
+
expect(result.action).toBe("allow");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("matches when both toolName and inputPattern match", async () => {
|
|
78
|
+
const hooks: HooksConfig = {
|
|
79
|
+
preToolUse: [
|
|
80
|
+
{
|
|
81
|
+
toolName: "Bash",
|
|
82
|
+
inputPattern: "sudo",
|
|
83
|
+
action: "deny",
|
|
84
|
+
message: "no sudo",
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
// Both match
|
|
89
|
+
const result = await runPreToolHooks(hooks, "Bash", {
|
|
90
|
+
command: "sudo rm foo",
|
|
91
|
+
});
|
|
92
|
+
expect(result.action).toBe("deny");
|
|
93
|
+
|
|
94
|
+
// toolName matches but inputPattern doesn't
|
|
95
|
+
const result2 = await runPreToolHooks(hooks, "Bash", { command: "ls" });
|
|
96
|
+
expect(result2.action).toBe("allow");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("hook with no toolName or inputPattern matches everything", async () => {
|
|
100
|
+
const hooks: HooksConfig = {
|
|
101
|
+
preToolUse: [{ action: "deny", message: "global deny" }],
|
|
102
|
+
};
|
|
103
|
+
const result = await runPreToolHooks(hooks, "AnyTool", {});
|
|
104
|
+
expect(result.action).toBe("deny");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("runs command-based hook and denies on non-zero exit", async () => {
|
|
108
|
+
const hooks: HooksConfig = {
|
|
109
|
+
preToolUse: [{ toolName: "Bash", command: "exit 1" }],
|
|
110
|
+
};
|
|
111
|
+
const result = await runPreToolHooks(hooks, "Bash", {});
|
|
112
|
+
expect(result.action).toBe("deny");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("runs command-based hook and allows on zero exit", async () => {
|
|
116
|
+
const hooks: HooksConfig = {
|
|
117
|
+
preToolUse: [{ toolName: "Bash", command: "exit 0" }],
|
|
118
|
+
};
|
|
119
|
+
const result = await runPreToolHooks(hooks, "Bash", {});
|
|
120
|
+
expect(result.action).toBe("allow");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("runPostToolHooks", () => {
|
|
125
|
+
test("runs without error with no hooks", async () => {
|
|
126
|
+
// Should not throw
|
|
127
|
+
await runPostToolHooks({}, "Bash", {}, "output");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("runs without error with matching hook", async () => {
|
|
131
|
+
const hooks: HooksConfig = {
|
|
132
|
+
postToolUse: [{ toolName: "Bash", command: "true" }],
|
|
133
|
+
};
|
|
134
|
+
// Fire and forget, should not throw
|
|
135
|
+
await runPostToolHooks(hooks, "Bash", {}, "output");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("does not run hooks that don't match", async () => {
|
|
139
|
+
const hooks: HooksConfig = {
|
|
140
|
+
postToolUse: [{ toolName: "Write", command: "exit 1" }],
|
|
141
|
+
};
|
|
142
|
+
// Should not throw because the hook shouldn't match "Bash"
|
|
143
|
+
await runPostToolHooks(hooks, "Bash", {}, "output");
|
|
144
|
+
});
|
|
145
|
+
});
|