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,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
|
+
});
|