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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. 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
+ });