pi-shepherd 0.1.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 (55) hide show
  1. package/README.en.md +136 -0
  2. package/README.md +136 -0
  3. package/index.ts +229 -0
  4. package/node_modules/@pi-atelier/shared-utils/README.en.md +182 -0
  5. package/node_modules/@pi-atelier/shared-utils/README.md +182 -0
  6. package/node_modules/@pi-atelier/shared-utils/package.json +51 -0
  7. package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +120 -0
  8. package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +100 -0
  9. package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +152 -0
  10. package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +187 -0
  11. package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +170 -0
  12. package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +126 -0
  13. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +138 -0
  14. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +257 -0
  15. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +189 -0
  16. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +204 -0
  17. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +269 -0
  18. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +267 -0
  19. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +520 -0
  20. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +116 -0
  21. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +119 -0
  22. package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +145 -0
  23. package/node_modules/@pi-atelier/shared-utils/src/agents.ts +39 -0
  24. package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +42 -0
  25. package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +62 -0
  26. package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +100 -0
  27. package/node_modules/@pi-atelier/shared-utils/src/index.ts +71 -0
  28. package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +96 -0
  29. package/node_modules/@pi-atelier/shared-utils/src/paths.ts +23 -0
  30. package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +241 -0
  31. package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +191 -0
  32. package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +73 -0
  33. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +104 -0
  34. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +75 -0
  35. package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +172 -0
  36. package/node_modules/@pi-atelier/shared-utils/src/settings.ts +104 -0
  37. package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +149 -0
  38. package/node_modules/@pi-atelier/shared-utils/tsconfig.json +9 -0
  39. package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +24 -0
  40. package/package.json +49 -0
  41. package/rules.json +516 -0
  42. package/shepherd/ephemeral-shared.ts +14 -0
  43. package/shepherd/ephemeral.ts +52 -0
  44. package/shepherd/index.ts +39 -0
  45. package/shepherd/line-count.ts +86 -0
  46. package/shepherd/rules-editor.ts +135 -0
  47. package/shepherd/rules-tool.ts +99 -0
  48. package/shepherd/rules-validate.ts +44 -0
  49. package/shepherd/rules.ts +283 -0
  50. package/shepherd/state-tracker.ts +119 -0
  51. package/shepherd/tool-event-types.ts +31 -0
  52. package/shepherd/tool-hooks.ts +176 -0
  53. package/shepherd/worktree-check.ts +130 -0
  54. package/tsconfig.json +14 -0
  55. package/vitest.config.ts +13 -0
@@ -0,0 +1,257 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ clearProjectSettingsCache,
7
+ detectConfigConflicts,
8
+ getEffectiveConfig,
9
+ validateConfigSchema,
10
+ } from "../project-config";
11
+ import { getDisabledMcpServers, getEnabledTools } from "../project-tools";
12
+
13
+ const TEST_DIR = join(tmpdir(), "pi-shared-utils-test");
14
+
15
+ // ── 测试辅助 ────────────────────────────────────────────
16
+
17
+ function createProjectSettings(dir: string, content: Record<string, any>) {
18
+ const piDir = join(dir, ".pi");
19
+ mkdirSync(piDir, { recursive: true });
20
+ writeFileSync(join(piDir, "settings.json"), JSON.stringify(content, null, 2));
21
+ }
22
+
23
+ function cleanupDir(dir: string) {
24
+ if (existsSync(dir)) rmSync(dir, { recursive: true });
25
+ }
26
+
27
+ const SETTINGS_PATH = join(require("node:os").homedir(), ".pi/agent/settings.json");
28
+
29
+ /** 临时替换全局 settings.json 内容(用于测试三层合并) */
30
+ function mockGlobalSettings(content: Record<string, any>) {
31
+ const original = readFileSync(SETTINGS_PATH, "utf-8");
32
+ writeFileSync(SETTINGS_PATH, JSON.stringify(content, null, 2));
33
+ return () => writeFileSync(SETTINGS_PATH, original);
34
+ }
35
+
36
+ // ── 测试 ─────────────────────────────────────────────────
37
+
38
+ describe("project-config", () => {
39
+ beforeEach(() => {
40
+ cleanupDir(TEST_DIR);
41
+ clearProjectSettingsCache();
42
+ mkdirSync(TEST_DIR, { recursive: true });
43
+ });
44
+ afterEach(() => {
45
+ cleanupDir(TEST_DIR);
46
+ });
47
+
48
+ describe("getEffectiveConfig", () => {
49
+ it("无项目配置时返回默认值", () => {
50
+ const defaults = { threshold: 5000, enabled: true };
51
+ const result = getEffectiveConfig("nonexistent-section", defaults, TEST_DIR);
52
+ expect(result.config.threshold).toBe(5000);
53
+ expect(result.config.enabled).toBe(true);
54
+ });
55
+
56
+ it("项目配置覆盖默认值", () => {
57
+ createProjectSettings(TEST_DIR, {
58
+ context: { distillThreshold: 8000 },
59
+ });
60
+
61
+ const defaults = { distillThreshold: 5000, agingThreshold: 20 };
62
+ const result = getEffectiveConfig("context", defaults, TEST_DIR);
63
+ expect(result.config.distillThreshold).toBe(8000);
64
+ expect(result.config.agingThreshold).toBe(20);
65
+ expect(result.sources.distillThreshold).toBe("project");
66
+ });
67
+
68
+ it("嵌套对象 deep merge", () => {
69
+ createProjectSettings(TEST_DIR, {
70
+ retry: { provider: { timeoutMs: 120000 } },
71
+ });
72
+
73
+ const defaults = {
74
+ enabled: true,
75
+ maxRetries: 3,
76
+ provider: { timeoutMs: 600000, maxRetries: 2 },
77
+ };
78
+ const result = getEffectiveConfig("retry", defaults, TEST_DIR);
79
+ expect(result.config.provider.timeoutMs).toBe(120000);
80
+ expect(result.config.provider.maxRetries).toBe(2);
81
+ expect(result.config.enabled).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe("detectConfigConflicts", () => {
86
+ it("无项目配置时返回空", () => {
87
+ const conflicts = detectConfigConflicts(TEST_DIR);
88
+ expect(conflicts).toEqual([]);
89
+ });
90
+
91
+ it("检测到全局与项目值不同的冲突", () => {
92
+ createProjectSettings(TEST_DIR, {
93
+ context: { distillThreshold: 500 },
94
+ });
95
+
96
+ const conflicts = detectConfigConflicts(TEST_DIR);
97
+ // 只检查格式正确,不硬编码全局值
98
+ if (conflicts.length > 0) {
99
+ expect(conflicts[0].section).toBe("context");
100
+ expect(conflicts[0].key).toBe("distillThreshold");
101
+ expect(conflicts[0].projectValue).toBe(500);
102
+ }
103
+ });
104
+
105
+ it("跳过 prompts 和 skills 段", () => {
106
+ createProjectSettings(TEST_DIR, {
107
+ prompts: ["prompts/"],
108
+ skills: ["+skills/test/SKILL.md"],
109
+ });
110
+
111
+ const conflicts = detectConfigConflicts(TEST_DIR);
112
+ expect(conflicts).toEqual([]);
113
+ });
114
+ });
115
+
116
+ describe("getEnabledTools", () => {
117
+ it("无项目配置时返回全部工具", () => {
118
+ const tools = getEnabledTools(["vision_analyze", "payload_analyze", "code_search"], TEST_DIR);
119
+ expect(tools).toEqual(["vision_analyze", "payload_analyze", "code_search"]);
120
+ });
121
+
122
+ it("禁用指定工具", () => {
123
+ createProjectSettings(TEST_DIR, {
124
+ tools: { disabled: ["payload_analyze"] },
125
+ });
126
+
127
+ const tools = getEnabledTools(["vision_analyze", "payload_analyze", "code_search"], TEST_DIR);
128
+ expect(tools).toEqual(["vision_analyze", "code_search"]);
129
+ });
130
+
131
+ it("额外启用工具", () => {
132
+ createProjectSettings(TEST_DIR, {
133
+ tools: { enabled: ["custom_tool"] },
134
+ });
135
+
136
+ const tools = getEnabledTools(["vision_analyze"], TEST_DIR);
137
+ expect(tools).toContain("vision_analyze");
138
+ expect(tools).toContain("custom_tool");
139
+ });
140
+
141
+ it("同时禁用和启用", () => {
142
+ createProjectSettings(TEST_DIR, {
143
+ tools: { disabled: ["payload_analyze"], enabled: ["custom_tool"] },
144
+ });
145
+
146
+ const tools = getEnabledTools(["vision_analyze", "payload_analyze"], TEST_DIR);
147
+ expect(tools).toEqual(["vision_analyze", "custom_tool"]);
148
+ });
149
+ });
150
+
151
+ describe("数组合并策略", () => {
152
+ it("默认:数组字段被项目级替换", () => {
153
+ createProjectSettings(TEST_DIR, {
154
+ test: { items: [4, 5, 6] },
155
+ });
156
+
157
+ const defaults = { items: [1, 2, 3] };
158
+ const result = getEffectiveConfig("test", defaults, TEST_DIR);
159
+ expect(result.config.items).toEqual([4, 5, 6]);
160
+ });
161
+
162
+ it("concatMerge 策略:数组字段做追加而非替换", () => {
163
+ createProjectSettings(TEST_DIR, {
164
+ shepherd: {
165
+ rules: [{ comment: "项目规则", hook: "tool_call", tool: "bash", action: "notify", reason: "test" }],
166
+ },
167
+ });
168
+
169
+ const defaults = {
170
+ rules: [{ comment: "全局规则", hook: "tool_call", tool: "bash", action: "block", reason: "test" }],
171
+ };
172
+ const result = getEffectiveConfig("shepherd", defaults, TEST_DIR, {
173
+ arrayMerge: "concat",
174
+ });
175
+ // 全局 + 项目 追加
176
+ expect(result.config.rules).toHaveLength(2);
177
+ expect(result.config.rules[0].comment).toBe("全局规则");
178
+ expect(result.config.rules[1].comment).toBe("项目规则");
179
+ });
180
+
181
+ it("concatMerge 策略:无项目级配置时不改变数组", () => {
182
+ const defaults = {
183
+ rules: [{ comment: "唯一规则" }],
184
+ };
185
+ const result = getEffectiveConfig("shepherd", defaults, TEST_DIR, {
186
+ arrayMerge: "concat",
187
+ });
188
+ expect(result.config.rules).toHaveLength(1);
189
+ expect(result.config.rules[0].comment).toBe("唯一规则");
190
+ });
191
+ });
192
+
193
+ describe("validateConfigSchema", () => {
194
+ it("类型一致时无错误", () => {
195
+ createProjectSettings(TEST_DIR, {
196
+ context: { distillThreshold: 8000 },
197
+ });
198
+
199
+ const errors = validateConfigSchema("context", { distillThreshold: 5000 }, TEST_DIR);
200
+ expect(errors).toEqual([]);
201
+ });
202
+
203
+ it("类型不一致时报错", () => {
204
+ createProjectSettings(TEST_DIR, {
205
+ context: { distillThreshold: "8000" },
206
+ });
207
+
208
+ const errors = validateConfigSchema("context", { distillThreshold: 5000 }, TEST_DIR);
209
+ expect(errors.length).toBeGreaterThan(0);
210
+ expect(errors[0].key).toBe("distillThreshold");
211
+ expect(errors[0].expectedType).toBe("number");
212
+ expect(errors[0].actualType).toBe("string");
213
+ });
214
+
215
+ it("无项目配置时无错误", () => {
216
+ const errors = validateConfigSchema("context", { distillThreshold: 5000 }, TEST_DIR);
217
+ expect(errors).toEqual([]);
218
+ });
219
+
220
+ it("嵌套对象类型不一致", () => {
221
+ createProjectSettings(TEST_DIR, {
222
+ retry: { provider: "not-an-object" },
223
+ });
224
+
225
+ const defaults = { provider: { timeoutMs: 60000 } };
226
+ const errors = validateConfigSchema("retry", defaults, TEST_DIR);
227
+ expect(errors.length).toBeGreaterThan(0);
228
+ expect(errors[0].key).toBe("provider");
229
+ });
230
+ });
231
+
232
+ describe("getDisabledMcpServers", () => {
233
+ it("无配置时返回空", () => {
234
+ const restore = mockGlobalSettings({});
235
+ try {
236
+ const disabled = getDisabledMcpServers(TEST_DIR);
237
+ expect(disabled).toEqual([]);
238
+ } finally {
239
+ restore();
240
+ }
241
+ });
242
+
243
+ it("返回禁用的 MCP 服务器", () => {
244
+ const restore = mockGlobalSettings({});
245
+ try {
246
+ createProjectSettings(TEST_DIR, {
247
+ mcp: { disabled: ["code-graph", "glm-web-search"] },
248
+ });
249
+
250
+ const disabled = getDisabledMcpServers(TEST_DIR);
251
+ expect(disabled).toEqual(["code-graph", "glm-web-search"]);
252
+ } finally {
253
+ restore();
254
+ }
255
+ });
256
+ });
257
+ });
@@ -0,0 +1,189 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { SETTINGS_PATH } from "../paths";
6
+ import { clearProjectSettingsCache } from "../project-config";
7
+ import { getDisabledTools, isToolDisabled } from "../project-tools";
8
+
9
+ const TEST_DIR = join(tmpdir(), "pi-shared-utils-mcp-tools-test");
10
+
11
+ // ── 测试辅助 ────────────────────────────────────────────
12
+
13
+ function createProjectSettings(dir: string, content: Record<string, any>) {
14
+ const piDir = join(dir, ".pi");
15
+ mkdirSync(piDir, { recursive: true });
16
+ writeFileSync(join(piDir, "settings.json"), JSON.stringify(content, null, 2));
17
+ }
18
+
19
+ function cleanupDir(dir: string) {
20
+ if (existsSync(dir)) rmSync(dir, { recursive: true });
21
+ }
22
+
23
+ /** 临时替换全局 settings.json 内容 */
24
+ function mockGlobalSettings(content: Record<string, any>) {
25
+ const original = readFileSync(SETTINGS_PATH, "utf-8");
26
+ writeFileSync(SETTINGS_PATH, JSON.stringify(content, null, 2));
27
+ return () => writeFileSync(SETTINGS_PATH, original);
28
+ }
29
+
30
+ // ── getDisabledTools ────────────────────────────────────
31
+
32
+ describe("getDisabledTools — 工具级禁用", () => {
33
+ beforeEach(() => {
34
+ cleanupDir(TEST_DIR);
35
+ clearProjectSettingsCache();
36
+ mkdirSync(TEST_DIR, { recursive: true });
37
+ });
38
+ afterEach(() => {
39
+ cleanupDir(TEST_DIR);
40
+ });
41
+
42
+ it("无配置时返回空数组", () => {
43
+ const restore = mockGlobalSettings({});
44
+ try {
45
+ const result = getDisabledTools(TEST_DIR);
46
+ expect(result).toEqual([]);
47
+ } finally {
48
+ restore();
49
+ }
50
+ });
51
+
52
+ it("全局工具级禁用", () => {
53
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_export_build"] } } });
54
+ try {
55
+ const result = getDisabledTools(TEST_DIR);
56
+ expect(result).toEqual(["godot_export_build"]);
57
+ } finally {
58
+ restore();
59
+ }
60
+ });
61
+
62
+ it("全局 + 项目 concat", () => {
63
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_export_build"] } } });
64
+ try {
65
+ createProjectSettings(TEST_DIR, { mcp: { tools: { disabled: ["godot_launch_editor"] } } });
66
+ const result = getDisabledTools(TEST_DIR);
67
+ expect(result).toEqual(["godot_export_build", "godot_launch_editor"]);
68
+ } finally {
69
+ restore();
70
+ }
71
+ });
72
+
73
+ it("项目 enabled 白名单覆盖", () => {
74
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_export_build", "godot_launch_editor"] } } });
75
+ try {
76
+ createProjectSettings(TEST_DIR, { mcp: { tools: { enabled: ["godot_launch_editor"] } } });
77
+ const result = getDisabledTools(TEST_DIR);
78
+ expect(result).toEqual(["godot_export_build"]);
79
+ } finally {
80
+ restore();
81
+ }
82
+ });
83
+
84
+ it("去重", () => {
85
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_export_build"] } } });
86
+ try {
87
+ createProjectSettings(TEST_DIR, { mcp: { tools: { disabled: ["godot_export_build"] } } });
88
+ const result = getDisabledTools(TEST_DIR);
89
+ expect(result).toEqual(["godot_export_build"]);
90
+ } finally {
91
+ restore();
92
+ }
93
+ });
94
+
95
+ it("与 server 级 disabled 独立", () => {
96
+ const restore = mockGlobalSettings({ mcp: { disabled: ["godot"], tools: { disabled: ["glm_web_search"] } } });
97
+ try {
98
+ const result = getDisabledTools(TEST_DIR);
99
+ expect(result).toEqual(["glm_web_search"]);
100
+ } finally {
101
+ restore();
102
+ }
103
+ });
104
+ });
105
+
106
+ // ── isToolDisabled — glob 匹配 ──────────────────────────
107
+
108
+ describe("isToolDisabled — glob 匹配", () => {
109
+ beforeEach(() => {
110
+ cleanupDir(TEST_DIR);
111
+ clearProjectSettingsCache();
112
+ mkdirSync(TEST_DIR, { recursive: true });
113
+ });
114
+ afterEach(() => {
115
+ cleanupDir(TEST_DIR);
116
+ });
117
+
118
+ it("精确匹配", () => {
119
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_launch_editor"] } } });
120
+ try {
121
+ expect(isToolDisabled("godot_launch_editor", TEST_DIR)).toBe(true);
122
+ expect(isToolDisabled("godot_run_project", TEST_DIR)).toBe(false);
123
+ } finally {
124
+ restore();
125
+ }
126
+ });
127
+
128
+ it("glob * 匹配", () => {
129
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_export_*"] } } });
130
+ try {
131
+ expect(isToolDisabled("godot_export_build", TEST_DIR)).toBe(true);
132
+ expect(isToolDisabled("godot_export_list_presets", TEST_DIR)).toBe(true);
133
+ expect(isToolDisabled("godot_run_project", TEST_DIR)).toBe(false);
134
+ } finally {
135
+ restore();
136
+ }
137
+ });
138
+
139
+ it("多个 * 通配符", () => {
140
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_*_editor"] } } });
141
+ try {
142
+ expect(isToolDisabled("godot_launch_editor", TEST_DIR)).toBe(true);
143
+ expect(isToolDisabled("godot_game_bridge_uninstall", TEST_DIR)).toBe(false);
144
+ } finally {
145
+ restore();
146
+ }
147
+ });
148
+
149
+ it("混合精确和 glob", () => {
150
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_export_*", "godot_launch_editor"] } } });
151
+ try {
152
+ expect(isToolDisabled("godot_export_build", TEST_DIR)).toBe(true);
153
+ expect(isToolDisabled("godot_launch_editor", TEST_DIR)).toBe(true);
154
+ expect(isToolDisabled("godot_run_project", TEST_DIR)).toBe(false);
155
+ } finally {
156
+ restore();
157
+ }
158
+ });
159
+
160
+ it("无禁用时全部通过", () => {
161
+ const restore = mockGlobalSettings({});
162
+ try {
163
+ expect(isToolDisabled("godot_anything", TEST_DIR)).toBe(false);
164
+ } finally {
165
+ restore();
166
+ }
167
+ });
168
+
169
+ it("项目级 enabled 覆盖全局 disabled", () => {
170
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["godot_export_*"] } } });
171
+ try {
172
+ createProjectSettings(TEST_DIR, { mcp: { tools: { enabled: ["godot_export_*"] } } });
173
+ expect(isToolDisabled("godot_export_build", TEST_DIR)).toBe(false);
174
+ } finally {
175
+ restore();
176
+ }
177
+ });
178
+
179
+ it("特殊字符在模式中被正确转义", () => {
180
+ const restore = mockGlobalSettings({ mcp: { tools: { disabled: ["code_graph_pi-core_*"] } } });
181
+ try {
182
+ // "-" 不是 glob 特殊字符,应该精确匹配
183
+ expect(isToolDisabled("code_graph_pi-core_semantic_code_search", TEST_DIR)).toBe(true);
184
+ expect(isToolDisabled("code_graph_pi_core_semantic_code_search", TEST_DIR)).toBe(false);
185
+ } finally {
186
+ restore();
187
+ }
188
+ });
189
+ });
@@ -0,0 +1,204 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { SETTINGS_PATH } from "../paths";
6
+ import { clearProjectSettingsCache } from "../project-config";
7
+ import {
8
+ getDisabledMcpServers,
9
+ getDisabledTools,
10
+ getEffectiveToolFilter,
11
+ getEnabledTools,
12
+ isToolDisabled,
13
+ } from "../project-tools";
14
+
15
+ const TEST_DIR = join(tmpdir(), "pi-shared-utils-tools-test");
16
+
17
+ // ── 测试辅助 ────────────────────────────────────────────
18
+
19
+ function createProjectSettings(dir: string, content: Record<string, any>) {
20
+ const piDir = join(dir, ".pi");
21
+ mkdirSync(piDir, { recursive: true });
22
+ writeFileSync(join(piDir, "settings.json"), JSON.stringify(content, null, 2));
23
+ }
24
+
25
+ function cleanupDir(dir: string) {
26
+ if (existsSync(dir)) rmSync(dir, { recursive: true });
27
+ }
28
+
29
+ /** 临时替换全局 settings.json 内容(用于测试三层合并) */
30
+ function mockGlobalSettings(content: Record<string, any>) {
31
+ const original = readFileSync(SETTINGS_PATH, "utf-8");
32
+ writeFileSync(SETTINGS_PATH, JSON.stringify(content, null, 2));
33
+ return () => writeFileSync(SETTINGS_PATH, original);
34
+ }
35
+
36
+ // ── 测试 ─────────────────────────────────────────────────
37
+
38
+ describe("getDisabledMcpServers — 三层合并", () => {
39
+ beforeEach(() => {
40
+ cleanupDir(TEST_DIR);
41
+ clearProjectSettingsCache();
42
+ mkdirSync(TEST_DIR, { recursive: true });
43
+ });
44
+ afterEach(() => {
45
+ cleanupDir(TEST_DIR);
46
+ });
47
+
48
+ it("无项目配置时,只返回全局禁用列表", () => {
49
+ const restore = mockGlobalSettings({ mcp: { disabled: ["global-server"] } });
50
+ try {
51
+ const result = getDisabledMcpServers(TEST_DIR);
52
+ expect(result).toEqual(["global-server"]);
53
+ } finally {
54
+ restore();
55
+ }
56
+ });
57
+
58
+ it("无全局无项目配置时,返回空数组", () => {
59
+ const restore = mockGlobalSettings({});
60
+ try {
61
+ const result = getDisabledMcpServers(TEST_DIR);
62
+ expect(result).toEqual([]);
63
+ } finally {
64
+ restore();
65
+ }
66
+ });
67
+
68
+ it("全局 + 项目 concat 合并", () => {
69
+ const restore = mockGlobalSettings({ mcp: { disabled: ["unstable"] } });
70
+ try {
71
+ createProjectSettings(TEST_DIR, { mcp: { disabled: ["godot"] } });
72
+ const result = getDisabledMcpServers(TEST_DIR);
73
+ expect(result).toEqual(["unstable", "godot"]);
74
+ } finally {
75
+ restore();
76
+ }
77
+ });
78
+
79
+ it("全局和项目有重复时去重", () => {
80
+ const restore = mockGlobalSettings({ mcp: { disabled: ["shared-server"] } });
81
+ try {
82
+ createProjectSettings(TEST_DIR, { mcp: { disabled: ["shared-server", "extra"] } });
83
+ const result = getDisabledMcpServers(TEST_DIR);
84
+ expect(result).toEqual(["shared-server", "extra"]);
85
+ } finally {
86
+ restore();
87
+ }
88
+ });
89
+
90
+ it("只有项目级配置时", () => {
91
+ const restore = mockGlobalSettings({});
92
+ try {
93
+ createProjectSettings(TEST_DIR, { mcp: { disabled: ["godot", "glm"] } });
94
+ const result = getDisabledMcpServers(TEST_DIR);
95
+ expect(result).toEqual(["godot", "glm"]);
96
+ } finally {
97
+ restore();
98
+ }
99
+ });
100
+
101
+ it("全局禁用 + 项目 enabled 白名单覆盖", () => {
102
+ const restore = mockGlobalSettings({ mcp: { disabled: ["godot", "code-graph", "glm-web-search"] } });
103
+ try {
104
+ // 项目启用 godot,其他保持禁用
105
+ createProjectSettings(TEST_DIR, { mcp: { enabled: ["godot"] } });
106
+ const result = getDisabledMcpServers(TEST_DIR);
107
+ expect(result).toEqual(["code-graph", "glm-web-search"]);
108
+ expect(result).not.toContain("godot");
109
+ } finally {
110
+ restore();
111
+ }
112
+ });
113
+
114
+ it("全局禁用 + 项目 enabled + 项目 disabled 同时生效", () => {
115
+ const restore = mockGlobalSettings({ mcp: { disabled: ["godot", "code-graph"] } });
116
+ try {
117
+ createProjectSettings(TEST_DIR, {
118
+ mcp: {
119
+ enabled: ["godot"], // 启用 godot(从 disabled 中移除)
120
+ disabled: ["glm-web-search"], // 额外禁用
121
+ },
122
+ });
123
+ const result = getDisabledMcpServers(TEST_DIR);
124
+ expect(result).toEqual(["code-graph", "glm-web-search"]);
125
+ expect(result).not.toContain("godot");
126
+ } finally {
127
+ restore();
128
+ }
129
+ });
130
+ });
131
+
132
+ describe("getEffectiveToolFilter — 三层合并", () => {
133
+ beforeEach(() => {
134
+ cleanupDir(TEST_DIR);
135
+ clearProjectSettingsCache();
136
+ mkdirSync(TEST_DIR, { recursive: true });
137
+ });
138
+ afterEach(() => {
139
+ cleanupDir(TEST_DIR);
140
+ });
141
+
142
+ it("无配置时返回空 filter", () => {
143
+ const filter = getEffectiveToolFilter(TEST_DIR);
144
+ expect(filter.disabled).toBeUndefined();
145
+ expect(filter.enabled).toBeUndefined();
146
+ });
147
+
148
+ it("全局 + 项目 concat", () => {
149
+ const restore = mockGlobalSettings({ tools: { disabled: ["global-tool"] } });
150
+ try {
151
+ createProjectSettings(TEST_DIR, { tools: { disabled: ["project-tool"] } });
152
+ const filter = getEffectiveToolFilter(TEST_DIR);
153
+ expect(filter.disabled).toEqual(["global-tool", "project-tool"]);
154
+ } finally {
155
+ restore();
156
+ }
157
+ });
158
+ });
159
+
160
+ describe("getEnabledTools — 三层合并", () => {
161
+ beforeEach(() => {
162
+ cleanupDir(TEST_DIR);
163
+ clearProjectSettingsCache();
164
+ mkdirSync(TEST_DIR, { recursive: true });
165
+ });
166
+ afterEach(() => {
167
+ cleanupDir(TEST_DIR);
168
+ });
169
+
170
+ const ALL_TOOLS = ["read", "write", "edit", "bash", "grep", "find"];
171
+
172
+ it("无过滤时返回全部工具", () => {
173
+ const result = getEnabledTools(ALL_TOOLS, TEST_DIR);
174
+ expect(result).toEqual(ALL_TOOLS);
175
+ });
176
+
177
+ it("全局禁用 + 项目禁用 concat", () => {
178
+ const restore = mockGlobalSettings({ tools: { disabled: ["bash"] } });
179
+ try {
180
+ createProjectSettings(TEST_DIR, { tools: { disabled: ["grep"] } });
181
+ const result = getEnabledTools(ALL_TOOLS, TEST_DIR);
182
+ expect(result).toEqual(["read", "write", "edit", "find"]);
183
+ } finally {
184
+ restore();
185
+ }
186
+ });
187
+
188
+ it("全局和项目都禁用同一个工具时去重", () => {
189
+ const restore = mockGlobalSettings({ tools: { disabled: ["bash"] } });
190
+ try {
191
+ createProjectSettings(TEST_DIR, { tools: { disabled: ["bash", "edit"] } });
192
+ const result = getEnabledTools(ALL_TOOLS, TEST_DIR);
193
+ expect(result).toEqual(["read", "write", "grep", "find"]);
194
+ } finally {
195
+ restore();
196
+ }
197
+ });
198
+
199
+ it("enabled 补充不在 allTools 里的工具", () => {
200
+ createProjectSettings(TEST_DIR, { tools: { enabled: ["custom-tool"] } });
201
+ const result = getEnabledTools(ALL_TOOLS, TEST_DIR);
202
+ expect(result).toContain("custom-tool");
203
+ });
204
+ });