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,159 @@
1
+ import { test, expect, describe, beforeEach } from "bun:test";
2
+ import {
3
+ getAction,
4
+ getBindings,
5
+ setBinding,
6
+ resetBindings,
7
+ InputHistory,
8
+ } from "../ui/keybindings.ts";
9
+
10
+ beforeEach(() => {
11
+ resetBindings();
12
+ });
13
+
14
+ describe("default bindings", () => {
15
+ test("default bindings are loaded", () => {
16
+ const bindings = getBindings();
17
+ expect(bindings.length).toBeGreaterThan(0);
18
+
19
+ const actions = bindings.map((b) => b.action);
20
+ expect(actions).toContain("exit");
21
+ expect(actions).toContain("mode-switch");
22
+ expect(actions).toContain("autocomplete");
23
+ expect(actions).toContain("clear-screen");
24
+ expect(actions).toContain("undo");
25
+ });
26
+ });
27
+
28
+ describe("getAction", () => {
29
+ test("matches ctrl+c to exit", () => {
30
+ // ctrl+c: key="c", ctrl=true, shift=false, meta=false
31
+ const action = getAction("c", true, false, false);
32
+ expect(action).toBe("exit");
33
+ });
34
+
35
+ test("matches shift+tab to mode-switch", () => {
36
+ // shift+tab: key="tab", ctrl=false, shift=true, meta=false
37
+ const action = getAction("tab", false, true, false);
38
+ expect(action).toBe("mode-switch");
39
+ });
40
+
41
+ test("matches ctrl+l to clear-screen", () => {
42
+ const action = getAction("l", true, false, false);
43
+ expect(action).toBe("clear-screen");
44
+ });
45
+
46
+ test("returns null for unbound key", () => {
47
+ const action = getAction("q", true, true, true);
48
+ expect(action).toBeNull();
49
+ });
50
+
51
+ test("matches plain arrow keys", () => {
52
+ expect(getAction("up", false, false, false)).toBe("history-prev");
53
+ expect(getAction("down", false, false, false)).toBe("history-next");
54
+ });
55
+ });
56
+
57
+ describe("setBinding", () => {
58
+ test("overrides an existing binding", () => {
59
+ // Change exit from ctrl+c to ctrl+q
60
+ setBinding("exit", "ctrl+q");
61
+
62
+ // Old combo should not match
63
+ expect(getAction("c", true, false, false)).toBeNull();
64
+ // New combo should match
65
+ expect(getAction("q", true, false, false)).toBe("exit");
66
+ });
67
+
68
+ test("adds a new binding", () => {
69
+ setBinding("custom-action", "ctrl+shift+x");
70
+ const action = getAction("x", true, true, false);
71
+ expect(action).toBe("custom-action");
72
+ });
73
+ });
74
+
75
+ describe("InputHistory", () => {
76
+ test("push and prev navigation", () => {
77
+ const history = new InputHistory();
78
+ history.push("first");
79
+ history.push("second");
80
+ history.push("third");
81
+
82
+ // prev goes from most recent backwards
83
+ expect(history.prev("")).toBe("third");
84
+ expect(history.prev("")).toBe("second");
85
+ expect(history.prev("")).toBe("first");
86
+ });
87
+
88
+ test("prev returns null on empty history", () => {
89
+ const history = new InputHistory();
90
+ expect(history.prev("")).toBeNull();
91
+ });
92
+
93
+ test("next navigates forward", () => {
94
+ const history = new InputHistory();
95
+ history.push("first");
96
+ history.push("second");
97
+ history.push("third");
98
+
99
+ // Navigate to the beginning
100
+ history.prev("");
101
+ history.prev("");
102
+ history.prev("");
103
+
104
+ // Navigate forward
105
+ expect(history.next()).toBe("second");
106
+ expect(history.next()).toBe("third");
107
+ });
108
+
109
+ test("next returns empty string when past end (clears input)", () => {
110
+ const history = new InputHistory();
111
+ history.push("hello");
112
+
113
+ history.prev(""); // go to "hello"
114
+ expect(history.next()).toBe(""); // past the end clears input
115
+ });
116
+
117
+ test("next returns null when not navigating", () => {
118
+ const history = new InputHistory();
119
+ history.push("hello");
120
+ // Without calling prev first, index is -1
121
+ expect(history.next()).toBeNull();
122
+ });
123
+
124
+ test("push resets navigation index", () => {
125
+ const history = new InputHistory();
126
+ history.push("first");
127
+ history.push("second");
128
+
129
+ // Navigate back
130
+ history.prev("");
131
+ expect(history.prev("")).toBe("first");
132
+
133
+ // Push new entry resets index
134
+ history.push("third");
135
+ expect(history.prev("")).toBe("third");
136
+ });
137
+
138
+ test("does not push duplicate consecutive entries", () => {
139
+ const history = new InputHistory();
140
+ history.push("same");
141
+ history.push("same");
142
+ history.push("same");
143
+
144
+ // Only one entry should exist
145
+ expect(history.prev("")).toBe("same");
146
+ // Going further back returns null (stays at index 0)
147
+ const second = history.prev("");
148
+ expect(second).toBe("same"); // stays at same since it's the only entry
149
+ });
150
+
151
+ test("wraps at beginning (stays at first entry)", () => {
152
+ const history = new InputHistory();
153
+ history.push("only");
154
+
155
+ expect(history.prev("")).toBe("only");
156
+ // Calling prev again stays at the first entry
157
+ expect(history.prev("")).toBe("only");
158
+ });
159
+ });
@@ -0,0 +1,82 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { getModelPatches, listPatches } from "../agent/model-patches.ts";
3
+
4
+ describe("Model Patches", () => {
5
+ describe("getModelPatches", () => {
6
+ test("matches grok models (not grok-4-1-fast)", () => {
7
+ const result = getModelPatches("grok-4");
8
+ expect(result.names).toContain("Grok verbosity control");
9
+ expect(result.names).not.toContain("Grok fast mode");
10
+ expect(result.combinedSuffix).toContain("Be concise");
11
+ });
12
+
13
+ test("matches grok-4-1-fast specifically", () => {
14
+ const result = getModelPatches("grok-4-1-fast");
15
+ expect(result.names).toContain("Grok fast mode");
16
+ // The generic grok pattern uses negative lookahead to exclude grok-4-1-fast
17
+ expect(result.names).not.toContain("Grok verbosity control");
18
+ expect(result.combinedSuffix).toContain("fast mode");
19
+ });
20
+
21
+ test("matches claude sonnet", () => {
22
+ const result = getModelPatches("claude-3.5-sonnet");
23
+ expect(result.names).toContain("Sonnet conciseness");
24
+ expect(result.combinedSuffix).toContain("concise");
25
+ });
26
+
27
+ test("matches claude opus", () => {
28
+ const result = getModelPatches("claude-opus-4");
29
+ expect(result.names).toContain("Opus thoroughness");
30
+ expect(result.combinedSuffix).toContain("thorough");
31
+ });
32
+
33
+ test("returns empty for unknown model", () => {
34
+ const result = getModelPatches("some-random-model-v1");
35
+ expect(result.names).toEqual([]);
36
+ expect(result.combinedSuffix).toBe("");
37
+ });
38
+
39
+ test("anchored patterns don't match substrings incorrectly", () => {
40
+ // "grok" pattern is anchored with ^, so "my-grok" shouldn't match
41
+ const result = getModelPatches("my-grok-variant");
42
+ expect(result.names).not.toContain("Grok verbosity control");
43
+ expect(result.names).not.toContain("Grok fast mode");
44
+
45
+ // "claude" pattern is anchored with ^, so "not-claude-sonnet" shouldn't match
46
+ const result2 = getModelPatches("not-claude-sonnet");
47
+ expect(result2.names).not.toContain("Sonnet conciseness");
48
+
49
+ // "deepseek" anchored
50
+ const result3 = getModelPatches("my-deepseek");
51
+ expect(result3.names).not.toContain("DeepSeek format control");
52
+ });
53
+
54
+ test("matches case-insensitively", () => {
55
+ const result = getModelPatches("Claude-3.5-Sonnet");
56
+ expect(result.names).toContain("Sonnet conciseness");
57
+ });
58
+ });
59
+
60
+ describe("listPatches", () => {
61
+ test("returns all patches", () => {
62
+ const patches = listPatches();
63
+ expect(patches.length).toBeGreaterThanOrEqual(7);
64
+
65
+ const names = patches.map((p) => p.name);
66
+ expect(names).toContain("Grok verbosity control");
67
+ expect(names).toContain("Grok fast mode");
68
+ expect(names).toContain("Sonnet conciseness");
69
+ expect(names).toContain("Opus thoroughness");
70
+ expect(names).toContain("OpenAI reasoning");
71
+ expect(names).toContain("DeepSeek format control");
72
+ expect(names).toContain("Local model constraints");
73
+ });
74
+
75
+ test("returns a copy (not the internal array)", () => {
76
+ const a = listPatches();
77
+ const b = listPatches();
78
+ expect(a).not.toBe(b);
79
+ expect(a).toEqual(b);
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,121 @@
1
+ import { test, expect, describe, beforeEach } from "bun:test";
2
+ import {
3
+ checkRules,
4
+ setRules,
5
+ getRules,
6
+ resetPermissionsForTests,
7
+ type PermissionRule,
8
+ } from "../config/permissions.ts";
9
+
10
+ beforeEach(() => {
11
+ resetPermissionsForTests();
12
+ });
13
+
14
+ describe("matchesToolPattern (via checkRules)", () => {
15
+ // matchesToolPattern is internal, so we test it through checkRules.
16
+
17
+ test("exact match", () => {
18
+ setRules([{ tool: "Bash", action: "deny" }]);
19
+ expect(checkRules("Bash")).toBe("deny");
20
+ expect(checkRules("Read")).toBeNull();
21
+ });
22
+
23
+ test("prefix glob (e.g. 'File*')", () => {
24
+ setRules([{ tool: "File*", action: "allow" }]);
25
+ expect(checkRules("FileRead")).toBe("allow");
26
+ expect(checkRules("FileWrite")).toBe("allow");
27
+ expect(checkRules("Bash")).toBeNull();
28
+ });
29
+
30
+ test("suffix glob (e.g. '*Bash')", () => {
31
+ setRules([{ tool: "*Bash", action: "deny" }]);
32
+ expect(checkRules("DangerousBash")).toBe("deny");
33
+ expect(checkRules("Bash")).toBe("deny");
34
+ expect(checkRules("Read")).toBeNull();
35
+ });
36
+
37
+ test("wildcard '*' matches everything", () => {
38
+ setRules([{ tool: "*", action: "ask" }]);
39
+ expect(checkRules("Bash")).toBe("ask");
40
+ expect(checkRules("Read")).toBe("ask");
41
+ expect(checkRules("AnythingAtAll")).toBe("ask");
42
+ });
43
+ });
44
+
45
+ describe("checkRules", () => {
46
+ test("matches tool name + inputPattern", () => {
47
+ setRules([
48
+ { tool: "Bash", inputPattern: "rm\\s+-rf", action: "deny" },
49
+ ]);
50
+ expect(checkRules("Bash", { command: "rm -rf /" })).toBe("deny");
51
+ });
52
+
53
+ test("returns null when no rule matches", () => {
54
+ setRules([{ tool: "Bash", action: "deny" }]);
55
+ expect(checkRules("Write")).toBeNull();
56
+ });
57
+
58
+ test("returns null when tool matches but inputPattern does not", () => {
59
+ setRules([
60
+ { tool: "Bash", inputPattern: "sudo", action: "deny" },
61
+ ]);
62
+ expect(checkRules("Bash", { command: "ls -la" })).toBeNull();
63
+ });
64
+
65
+ test("matches with regex inputPattern", () => {
66
+ setRules([
67
+ { tool: "Bash", inputPattern: "\\bsudo\\b", action: "deny" },
68
+ ]);
69
+ expect(checkRules("Bash", { command: "sudo apt install" })).toBe("deny");
70
+ expect(checkRules("Bash", { command: "pseudocode" })).toBeNull();
71
+ });
72
+
73
+ test("first matching rule wins", () => {
74
+ setRules([
75
+ { tool: "Bash", inputPattern: "sudo", action: "deny" },
76
+ { tool: "Bash", action: "allow" },
77
+ ]);
78
+ // The first rule matches sudo commands
79
+ expect(checkRules("Bash", { command: "sudo rm" })).toBe("deny");
80
+ // The second rule catches all other Bash
81
+ expect(checkRules("Bash", { command: "ls" })).toBe("allow");
82
+ });
83
+
84
+ test("invalid regex in inputPattern is skipped", () => {
85
+ setRules([
86
+ { tool: "Bash", inputPattern: "[invalid", action: "deny" },
87
+ { tool: "Bash", action: "allow" },
88
+ ]);
89
+ // First rule has bad regex and is skipped; second rule matches
90
+ expect(checkRules("Bash", { command: "anything" })).toBe("allow");
91
+ });
92
+
93
+ test("no input provided — rules without inputPattern still match", () => {
94
+ setRules([{ tool: "Bash", action: "deny" }]);
95
+ expect(checkRules("Bash")).toBe("deny");
96
+ });
97
+ });
98
+
99
+ describe("setRules and getRules", () => {
100
+ test("setRules replaces rules and getRules returns them", () => {
101
+ const rules: PermissionRule[] = [
102
+ { tool: "Bash", action: "deny" },
103
+ { tool: "Read", action: "allow" },
104
+ ];
105
+ setRules(rules);
106
+ expect(getRules()).toBe(rules);
107
+ });
108
+
109
+ test("getRules returns empty array initially", () => {
110
+ expect(getRules()).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe("resetPermissionsForTests", () => {
115
+ test("clears all rules", () => {
116
+ setRules([{ tool: "Bash", action: "deny" }]);
117
+ expect(getRules().length).toBe(1);
118
+ resetPermissionsForTests();
119
+ expect(getRules()).toEqual([]);
120
+ });
121
+ });
@@ -0,0 +1,108 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import {
6
+ checkPermission,
7
+ allowForSession,
8
+ getPermissionState,
9
+ recordPermission,
10
+ resetPermissionsForTests,
11
+ } from "../config/permissions.ts";
12
+ import { setConfigDirForTests } from "../config/settings.ts";
13
+
14
+ let configDir: string;
15
+
16
+ beforeEach(() => {
17
+ configDir = mkdtempSync(join(tmpdir(), "ashlrcode-permissions-test-"));
18
+ setConfigDirForTests(configDir);
19
+ resetPermissionsForTests();
20
+ });
21
+
22
+ afterEach(() => {
23
+ resetPermissionsForTests();
24
+ setConfigDirForTests(null);
25
+ if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("checkPermission", () => {
29
+ // Note: tests interact with module-level state. The permission module uses
30
+ // a global singleton state. We test the logic as-is.
31
+
32
+ test("auto-allows read-only tools", () => {
33
+ expect(checkPermission("Read")).toBe("allow");
34
+ expect(checkPermission("Glob")).toBe("allow");
35
+ expect(checkPermission("Grep")).toBe("allow");
36
+ expect(checkPermission("AskUser")).toBe("allow");
37
+ expect(checkPermission("WebFetch")).toBe("allow");
38
+ expect(checkPermission("Agent")).toBe("allow");
39
+ });
40
+
41
+ test("returns 'ask' for unknown non-read-only tools", () => {
42
+ // A tool not in the auto-allow list and not in any permission set
43
+ expect(checkPermission("SomeRandomTool_" + Date.now())).toBe("ask");
44
+ });
45
+
46
+ test("allowForSession makes tool return 'allow'", () => {
47
+ const toolName = `SessionTool_${Date.now()}`;
48
+ expect(checkPermission(toolName)).toBe("ask");
49
+ allowForSession(toolName);
50
+ expect(checkPermission(toolName)).toBe("allow");
51
+ });
52
+ });
53
+
54
+ describe("recordPermission", () => {
55
+ test("always_allow persists and changes check result", async () => {
56
+ const toolName = `AlwaysAllowTool_${Date.now()}`;
57
+ expect(checkPermission(toolName)).toBe("ask");
58
+ await recordPermission(toolName, "always_allow");
59
+ expect(checkPermission(toolName)).toBe("allow");
60
+ // Clean up: remove from state
61
+ const state = getPermissionState();
62
+ state.alwaysAllow.delete(toolName);
63
+ });
64
+
65
+ test("always_deny persists and changes check result", async () => {
66
+ const toolName = `AlwaysDenyTool_${Date.now()}`;
67
+ await recordPermission(toolName, "always_deny");
68
+ expect(checkPermission(toolName)).toBe("deny");
69
+ // Clean up
70
+ const state = getPermissionState();
71
+ state.alwaysDeny.delete(toolName);
72
+ });
73
+
74
+ test("always_allow removes from alwaysDeny", async () => {
75
+ const toolName = `FlipTool_${Date.now()}`;
76
+ await recordPermission(toolName, "always_deny");
77
+ expect(checkPermission(toolName)).toBe("deny");
78
+ await recordPermission(toolName, "always_allow");
79
+ expect(checkPermission(toolName)).toBe("allow");
80
+ // Clean up
81
+ const state = getPermissionState();
82
+ state.alwaysAllow.delete(toolName);
83
+ });
84
+
85
+ test("always_deny removes from alwaysAllow", async () => {
86
+ const toolName = `FlipTool2_${Date.now()}`;
87
+ await recordPermission(toolName, "always_allow");
88
+ expect(checkPermission(toolName)).toBe("allow");
89
+ await recordPermission(toolName, "always_deny");
90
+ expect(checkPermission(toolName)).toBe("deny");
91
+ // Clean up
92
+ const state = getPermissionState();
93
+ state.alwaysDeny.delete(toolName);
94
+ });
95
+
96
+ test("allow_once does not persist", async () => {
97
+ const toolName = `OnceTool_${Date.now()}`;
98
+ await recordPermission(toolName, "allow_once");
99
+ // Still "ask" because allow_once doesn't persist
100
+ expect(checkPermission(toolName)).toBe("ask");
101
+ });
102
+
103
+ test("deny_once does not persist", async () => {
104
+ const toolName = `DenyOnceTool_${Date.now()}`;
105
+ await recordPermission(toolName, "deny_once");
106
+ expect(checkPermission(toolName)).toBe("ask");
107
+ });
108
+ });
@@ -0,0 +1,63 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { loadProjectConfig } from "../config/project-config.ts";
6
+ import { setConfigDirForTests } from "../config/settings.ts";
7
+
8
+ describe("loadProjectConfig", () => {
9
+ let rootDir: string;
10
+ let configDir: string;
11
+
12
+ beforeEach(() => {
13
+ rootDir = mkdtempSync(join(tmpdir(), "ashlrcode-project-config-"));
14
+ configDir = join(rootDir, ".config");
15
+ mkdirSync(configDir, { recursive: true });
16
+ setConfigDirForTests(configDir);
17
+ });
18
+
19
+ afterEach(() => {
20
+ setConfigDirForTests(null);
21
+ if (existsSync(rootDir)) rmSync(rootDir, { recursive: true, force: true });
22
+ });
23
+
24
+ test("loads global, parent, and cwd instructions in increasing precedence order", async () => {
25
+ const parentDir = join(rootDir, "workspace");
26
+ const childDir = join(parentDir, "app");
27
+ mkdirSync(childDir, { recursive: true });
28
+
29
+ writeFileSync(join(configDir, "ASHLR.md"), "global rule", "utf-8");
30
+ writeFileSync(join(parentDir, "ASHLR.md"), "parent rule", "utf-8");
31
+ writeFileSync(join(childDir, "CLAUDE.md"), "child rule", "utf-8");
32
+
33
+ const config = await loadProjectConfig(childDir);
34
+
35
+ expect(config.sources).toEqual([
36
+ join(configDir, "ASHLR.md"),
37
+ join(parentDir, "ASHLR.md"),
38
+ join(childDir, "CLAUDE.md"),
39
+ ]);
40
+ expect(config.instructions.indexOf("global rule")).toBeLessThan(
41
+ config.instructions.indexOf("parent rule")
42
+ );
43
+ expect(config.instructions.indexOf("parent rule")).toBeLessThan(
44
+ config.instructions.indexOf("child rule")
45
+ );
46
+ });
47
+
48
+ test("walks ancestor directories and includes both ASHLR.md and CLAUDE.md", async () => {
49
+ const parentDir = join(rootDir, "workspace");
50
+ const childDir = join(parentDir, "app");
51
+ mkdirSync(childDir, { recursive: true });
52
+
53
+ writeFileSync(join(rootDir, "ASHLR.md"), "root rule", "utf-8");
54
+ writeFileSync(join(parentDir, "CLAUDE.md"), "parent claude", "utf-8");
55
+
56
+ const config = await loadProjectConfig(childDir);
57
+
58
+ expect(config.sources).toContain(join(rootDir, "ASHLR.md"));
59
+ expect(config.sources).toContain(join(parentDir, "CLAUDE.md"));
60
+ expect(config.instructions).toContain("root rule");
61
+ expect(config.instructions).toContain("parent claude");
62
+ });
63
+ });