pi-shepherd 0.1.1 → 0.2.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/README.en.md +206 -83
- package/README.md +209 -88
- package/index.ts +262 -229
- package/package.json +60 -58
- package/rules.json +197 -218
- package/shepherd/compaction.ts +76 -0
- package/shepherd/conditions.ts +98 -0
- package/shepherd/ephemeral.ts +55 -52
- package/shepherd/git.ts +64 -0
- package/shepherd/index.ts +14 -0
- package/shepherd/line-count.ts +86 -86
- package/shepherd/message-end.ts +120 -0
- package/shepherd/rules-editor.ts +250 -135
- package/shepherd/rules-tool-helpers.ts +119 -0
- package/shepherd/rules-tool-list.ts +124 -0
- package/shepherd/rules-tool.ts +182 -99
- package/shepherd/rules-validate.ts +89 -44
- package/shepherd/rules.ts +366 -283
- package/shepherd/tool-event-types.ts +27 -14
- package/shepherd/tool-hooks.ts +165 -176
- package/shepherd/worktree-check.ts +130 -130
- package/tsconfig.json +21 -14
- package/node_modules/@pi-atelier/shared-utils/README.en.md +0 -182
- package/node_modules/@pi-atelier/shared-utils/README.md +0 -182
- package/node_modules/@pi-atelier/shared-utils/package.json +0 -51
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +0 -120
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +0 -100
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +0 -152
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +0 -187
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +0 -170
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +0 -126
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +0 -138
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +0 -257
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +0 -189
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +0 -204
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +0 -269
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +0 -267
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +0 -520
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +0 -116
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +0 -119
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +0 -145
- package/node_modules/@pi-atelier/shared-utils/src/agents.ts +0 -39
- package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +0 -42
- package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +0 -62
- package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +0 -100
- package/node_modules/@pi-atelier/shared-utils/src/index.ts +0 -71
- package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +0 -96
- package/node_modules/@pi-atelier/shared-utils/src/paths.ts +0 -23
- package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +0 -241
- package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +0 -191
- package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +0 -73
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +0 -104
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +0 -75
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +0 -172
- package/node_modules/@pi-atelier/shared-utils/src/settings.ts +0 -104
- package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +0 -149
- package/node_modules/@pi-atelier/shared-utils/tsconfig.json +0 -9
- package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +0 -24
package/shepherd/rules-editor.ts
CHANGED
|
@@ -1,135 +1,250 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shepherd 规则文件安全编辑器
|
|
3
|
-
*
|
|
4
|
-
* 提供 rules.json 的安全增删改查:
|
|
5
|
-
* - 写入前校验(必填字段、正则合法性、枚举值)
|
|
6
|
-
* - 自动备份(.bak)
|
|
7
|
-
* - 写入后回读验证
|
|
8
|
-
* - 失败自动回滚
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import * as fs from "node:fs";
|
|
12
|
-
import * as path from "node:path";
|
|
13
|
-
import { validateRule } from "./rules-validate";
|
|
14
|
-
|
|
15
|
-
export type { ValidationResult } from "./rules-validate";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shepherd 规则文件安全编辑器
|
|
3
|
+
*
|
|
4
|
+
* 提供 rules.json 的安全增删改查:
|
|
5
|
+
* - 写入前校验(必填字段、正则合法性、枚举值)
|
|
6
|
+
* - 自动备份(.bak)
|
|
7
|
+
* - 写入后回读验证
|
|
8
|
+
* - 失败自动回滚
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { validateRule } from "./rules-validate";
|
|
14
|
+
|
|
15
|
+
export type { ValidationResult } from "./rules-validate";
|
|
16
|
+
export { validateRule } from "./rules-validate";
|
|
17
|
+
|
|
18
|
+
// ── 类型 ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface RuleSummary {
|
|
21
|
+
index: number;
|
|
22
|
+
comment: string;
|
|
23
|
+
action?: string;
|
|
24
|
+
tool?: string;
|
|
25
|
+
hook?: string;
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
pattern?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ListResult {
|
|
31
|
+
rules: RuleSummary[];
|
|
32
|
+
count: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RuleDetail {
|
|
37
|
+
index: number;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ListDetailResult {
|
|
42
|
+
rules: RuleDetail[];
|
|
43
|
+
count: number;
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WriteResult {
|
|
48
|
+
success: boolean;
|
|
49
|
+
error?: string;
|
|
50
|
+
index?: number;
|
|
51
|
+
deleted?: { comment: string; [key: string]: unknown };
|
|
52
|
+
overwritten?: boolean;
|
|
53
|
+
warning?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── 文件操作辅助 ──────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
interface FileData {
|
|
59
|
+
rules: Record<string, unknown>[];
|
|
60
|
+
error?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readFile(filePath: string): FileData {
|
|
64
|
+
if (!fs.existsSync(filePath)) return { rules: [] };
|
|
65
|
+
try {
|
|
66
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
if (!Array.isArray(parsed)) {
|
|
69
|
+
return {
|
|
70
|
+
rules: [],
|
|
71
|
+
error: `顶层必须是 JSON 数组,当前是 ${typeof parsed}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { rules: parsed };
|
|
75
|
+
} catch (e: unknown) {
|
|
76
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
77
|
+
return { rules: [], error: `JSON 解析失败: ${msg}` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeWrite(
|
|
82
|
+
filePath: string,
|
|
83
|
+
rules: Record<string, unknown>[],
|
|
84
|
+
): WriteResult {
|
|
85
|
+
const backupPath = `${filePath}.bak`;
|
|
86
|
+
if (fs.existsSync(filePath)) fs.copyFileSync(filePath, backupPath);
|
|
87
|
+
const dir = path.dirname(filePath);
|
|
88
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
89
|
+
fs.writeFileSync(filePath, JSON.stringify(rules, null, "\t"), "utf-8");
|
|
90
|
+
try {
|
|
91
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
if (!Array.isArray(parsed)) throw new Error("not array");
|
|
94
|
+
return { success: true };
|
|
95
|
+
} catch {
|
|
96
|
+
if (fs.existsSync(backupPath)) fs.copyFileSync(backupPath, filePath);
|
|
97
|
+
return { success: false, error: "写入后验证失败,已从备份恢复" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 去重签名 ─────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** 规范化 conditions 数组:按 field+pattern 排序,取关键属性 */
|
|
104
|
+
function normalizeConditions(
|
|
105
|
+
conditions: Array<Record<string, unknown>>,
|
|
106
|
+
): string {
|
|
107
|
+
const normalized = conditions
|
|
108
|
+
.map((c) => ({
|
|
109
|
+
f: (c.field as string) || "",
|
|
110
|
+
p: (c.pattern as string) || "",
|
|
111
|
+
}))
|
|
112
|
+
.sort((a, b) => (a.f + a.p).localeCompare(b.f + b.p));
|
|
113
|
+
return JSON.stringify(normalized);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 生成规则去重签名 key */
|
|
117
|
+
export function dedupKey(rule: Record<string, unknown>): string {
|
|
118
|
+
const tool = (rule.tool as string) || "";
|
|
119
|
+
const hook = (rule.hook as string) || "";
|
|
120
|
+
const action = (rule.action as string) || "";
|
|
121
|
+
let trigger: string;
|
|
122
|
+
if (Array.isArray(rule.conditions) && rule.conditions.length > 0) {
|
|
123
|
+
trigger = normalizeConditions(
|
|
124
|
+
rule.conditions as Array<Record<string, unknown>>,
|
|
125
|
+
);
|
|
126
|
+
} else if (rule.pattern) {
|
|
127
|
+
trigger = rule.pattern as string;
|
|
128
|
+
} else if (rule.check) {
|
|
129
|
+
trigger = rule.check as string;
|
|
130
|
+
} else {
|
|
131
|
+
trigger = "";
|
|
132
|
+
}
|
|
133
|
+
return `${tool}|${hook}|${trigger}|${action}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 在规则数组中查找签名相同的规则,返回 index 或 null */
|
|
137
|
+
export function findDuplicateBySignature(
|
|
138
|
+
rules: Record<string, unknown>[],
|
|
139
|
+
newRule: Record<string, unknown>,
|
|
140
|
+
): number | null {
|
|
141
|
+
const newKey = dedupKey(newRule);
|
|
142
|
+
for (let i = 0; i < rules.length; i++) {
|
|
143
|
+
if (dedupKey(rules[i]) === newKey) return i;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── 公开 API ─────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export function listRules(filePath: string): ListResult {
|
|
151
|
+
const { rules, error } = readFile(filePath);
|
|
152
|
+
if (error) return { rules: [], count: 0, error };
|
|
153
|
+
return {
|
|
154
|
+
rules: rules.map((r, i) => ({
|
|
155
|
+
index: i,
|
|
156
|
+
comment: (r.comment as string) || "",
|
|
157
|
+
action: r.action as string | undefined,
|
|
158
|
+
tool: r.tool as string | undefined,
|
|
159
|
+
hook: r.hook as string | undefined,
|
|
160
|
+
enabled: r.enabled as boolean | undefined,
|
|
161
|
+
pattern: r.pattern as string | undefined,
|
|
162
|
+
})),
|
|
163
|
+
count: rules.length,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listRulesDetail(filePath: string): ListDetailResult {
|
|
168
|
+
const { rules, error } = readFile(filePath);
|
|
169
|
+
if (error) return { rules: [], count: 0, error };
|
|
170
|
+
return {
|
|
171
|
+
rules: rules.map((r, i) => ({ index: i, ...r })),
|
|
172
|
+
count: rules.length,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function getRuleDetail(
|
|
177
|
+
filePath: string,
|
|
178
|
+
index: number,
|
|
179
|
+
): RuleDetail | { error: string } {
|
|
180
|
+
const { rules, error } = readFile(filePath);
|
|
181
|
+
if (error) return { error };
|
|
182
|
+
if (index < 0 || index >= rules.length)
|
|
183
|
+
return { error: `编号越界: ${index}(共 ${rules.length} 条)` };
|
|
184
|
+
return { index, ...rules[index] };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function addRule(
|
|
188
|
+
filePath: string,
|
|
189
|
+
rule: Record<string, unknown>,
|
|
190
|
+
): WriteResult {
|
|
191
|
+
const { rules, error } = readFile(filePath);
|
|
192
|
+
if (error) return { success: false, error };
|
|
193
|
+
const validation = validateRule(rule);
|
|
194
|
+
if (!validation.valid)
|
|
195
|
+
return { success: false, error: validation.errors.join("; ") };
|
|
196
|
+
// 去重检测:签名相同则覆盖
|
|
197
|
+
const dupIdx = findDuplicateBySignature(rules, rule);
|
|
198
|
+
if (dupIdx !== null) {
|
|
199
|
+
rules[dupIdx] = rule;
|
|
200
|
+
const writeResult = safeWrite(filePath, rules);
|
|
201
|
+
return writeResult.success
|
|
202
|
+
? { success: true, index: dupIdx, overwritten: true }
|
|
203
|
+
: writeResult;
|
|
204
|
+
}
|
|
205
|
+
rules.push(rule);
|
|
206
|
+
const writeResult = safeWrite(filePath, rules);
|
|
207
|
+
return writeResult.success
|
|
208
|
+
? { success: true, index: rules.length - 1 }
|
|
209
|
+
: writeResult;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function updateRule(
|
|
213
|
+
filePath: string,
|
|
214
|
+
index: number,
|
|
215
|
+
changes: Record<string, unknown>,
|
|
216
|
+
): WriteResult {
|
|
217
|
+
if (index < 0) return { success: false, error: `编号越界: ${index}` };
|
|
218
|
+
const { rules, error } = readFile(filePath);
|
|
219
|
+
if (error) return { success: false, error };
|
|
220
|
+
if (index >= rules.length)
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
error: `编号越界: ${index}(共 ${rules.length} 条)`,
|
|
224
|
+
};
|
|
225
|
+
const merged = { ...rules[index], ...changes };
|
|
226
|
+
const validation = validateRule(merged);
|
|
227
|
+
if (!validation.valid)
|
|
228
|
+
return { success: false, error: validation.errors.join("; ") };
|
|
229
|
+
rules[index] = merged;
|
|
230
|
+
return safeWrite(filePath, rules);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function deleteRule(filePath: string, index: number): WriteResult {
|
|
234
|
+
if (index < 0) return { success: false, error: `编号越界: ${index}` };
|
|
235
|
+
const { rules, error } = readFile(filePath);
|
|
236
|
+
if (error) return { success: false, error };
|
|
237
|
+
if (index >= rules.length)
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
error: `编号越界: ${index}(共 ${rules.length} 条)`,
|
|
241
|
+
};
|
|
242
|
+
const deleted = rules.splice(index, 1)[0];
|
|
243
|
+
const writeResult = safeWrite(filePath, rules);
|
|
244
|
+
return writeResult.success
|
|
245
|
+
? {
|
|
246
|
+
success: true,
|
|
247
|
+
deleted: { comment: (deleted.comment as string) || "", ...deleted },
|
|
248
|
+
}
|
|
249
|
+
: writeResult;
|
|
250
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shepherd 规则工具辅助函数
|
|
3
|
+
*
|
|
4
|
+
* scope 路径推导、合并列表、跨 scope 去重检测。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { findDuplicateBySignature, listRules } from "./rules-editor";
|
|
10
|
+
|
|
11
|
+
/** 项目级规则文件名 */
|
|
12
|
+
const PROJECT_RULES_FILE = "shepherd-rules.json";
|
|
13
|
+
|
|
14
|
+
export type Scope = "global" | "project";
|
|
15
|
+
|
|
16
|
+
/** 根据 scope 推导规则文件路径 */
|
|
17
|
+
export function getRulesFilePath(
|
|
18
|
+
scope: Scope | undefined,
|
|
19
|
+
globalDir: string,
|
|
20
|
+
cwd: string,
|
|
21
|
+
): string {
|
|
22
|
+
if (scope === "project") {
|
|
23
|
+
return path.join(cwd, ".pi", "extensions", PROJECT_RULES_FILE);
|
|
24
|
+
}
|
|
25
|
+
// 默认 global
|
|
26
|
+
return path.join(globalDir, "rules.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ListRuleItem {
|
|
30
|
+
index: number;
|
|
31
|
+
comment: string;
|
|
32
|
+
scope: Scope;
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
action?: string;
|
|
35
|
+
tool?: string;
|
|
36
|
+
hook?: string;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 按_scope 列出规则。scope 不传时返回全局+项目合并列表。
|
|
42
|
+
*/
|
|
43
|
+
export function listRulesByScope(
|
|
44
|
+
globalDir: string,
|
|
45
|
+
cwd: string,
|
|
46
|
+
scope?: Scope,
|
|
47
|
+
): ListRuleItem[] {
|
|
48
|
+
const results: ListRuleItem[] = [];
|
|
49
|
+
|
|
50
|
+
if (!scope || scope === "global") {
|
|
51
|
+
const globalPath = path.join(globalDir, "rules.json");
|
|
52
|
+
if (fs.existsSync(globalPath)) {
|
|
53
|
+
const r = listRules(globalPath);
|
|
54
|
+
if (!r.error && r.rules) {
|
|
55
|
+
for (const rule of r.rules) {
|
|
56
|
+
results.push({ ...rule, scope: "global" });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!scope || scope === "project") {
|
|
63
|
+
const projectPath = path.join(cwd, ".pi", "extensions", PROJECT_RULES_FILE);
|
|
64
|
+
if (fs.existsSync(projectPath)) {
|
|
65
|
+
const r = listRules(projectPath);
|
|
66
|
+
if (!r.error && r.rules) {
|
|
67
|
+
for (const rule of r.rules) {
|
|
68
|
+
results.push({ ...rule, scope: "project" });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 跨 scope 去重检测:写入 A scope 时检查 B scope 是否有签名相同的规则。
|
|
79
|
+
* 返回 warning 字符串或 null。
|
|
80
|
+
*/
|
|
81
|
+
export function checkCrossScopeDuplicate(
|
|
82
|
+
targetScope: Scope,
|
|
83
|
+
globalDir: string,
|
|
84
|
+
cwd: string,
|
|
85
|
+
newRule: Record<string, unknown>,
|
|
86
|
+
): string | null {
|
|
87
|
+
const otherScope: Scope = targetScope === "global" ? "project" : "global";
|
|
88
|
+
const otherPath = getRulesFilePath(otherScope, globalDir, cwd);
|
|
89
|
+
|
|
90
|
+
if (!fs.existsSync(otherPath)) return null;
|
|
91
|
+
|
|
92
|
+
const content = fs.readFileSync(otherPath, "utf-8");
|
|
93
|
+
let rules: Record<string, unknown>[];
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(content);
|
|
96
|
+
if (!Array.isArray(parsed)) return null;
|
|
97
|
+
rules = parsed;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const dupIdx = findDuplicateBySignature(rules, newRule);
|
|
103
|
+
if (dupIdx !== null) {
|
|
104
|
+
const scopeLabel = otherScope === "global" ? "全局" : "项目级";
|
|
105
|
+
return `⚠ ${scopeLabel}已有相似规则 [${otherScope}:${dupIdx}]`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 确保项目级规则文件的目录存在
|
|
113
|
+
*/
|
|
114
|
+
export function ensureProjectDir(cwd: string): void {
|
|
115
|
+
const dir = path.join(cwd, ".pi", "extensions");
|
|
116
|
+
if (!fs.existsSync(dir)) {
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shepherd 规则工具 — list 输出格式化
|
|
3
|
+
*
|
|
4
|
+
* 处理 list 的两种输出模式:摘要模式和 verbose(完整 JSON)模式。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getRuleDetail, listRules, listRulesDetail } from "./rules-editor";
|
|
8
|
+
import {
|
|
9
|
+
getRulesFilePath,
|
|
10
|
+
listRulesByScope,
|
|
11
|
+
type Scope,
|
|
12
|
+
} from "./rules-tool-helpers";
|
|
13
|
+
|
|
14
|
+
/** 构造 pi 工具 execute 的标准返回格式 */
|
|
15
|
+
export function textResult(text: string) {
|
|
16
|
+
return { content: [{ type: "text" as const, text }] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const scopeLabel = (s: Scope) => (s === "global" ? "全局" : "项目级");
|
|
20
|
+
|
|
21
|
+
/** 按编号查看单条规则的完整 JSON */
|
|
22
|
+
export function handleListByIndex(
|
|
23
|
+
scope: Scope | undefined,
|
|
24
|
+
index: number,
|
|
25
|
+
rulesDir: string,
|
|
26
|
+
effectiveCwd: string,
|
|
27
|
+
) {
|
|
28
|
+
const effectiveScope = scope || "global";
|
|
29
|
+
const filePath = getRulesFilePath(effectiveScope, rulesDir, effectiveCwd);
|
|
30
|
+
const detail = getRuleDetail(filePath, index);
|
|
31
|
+
if ("error" in detail) return textResult(`❌ ${detail.error}`);
|
|
32
|
+
const { index: _, ...ruleData } = detail;
|
|
33
|
+
return textResult(
|
|
34
|
+
`📋 规则 [${effectiveScope}:${index}] 完整内容:\n${JSON.stringify(ruleData, null, "\t")}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** verbose 模式:显示每条规则的完整 JSON */
|
|
39
|
+
export function handleListVerbose(
|
|
40
|
+
scope: Scope | undefined,
|
|
41
|
+
rulesDir: string,
|
|
42
|
+
effectiveCwd: string,
|
|
43
|
+
) {
|
|
44
|
+
if (!scope) {
|
|
45
|
+
// 全部 scope
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
const globalPath = getRulesFilePath("global", rulesDir, effectiveCwd);
|
|
48
|
+
const projectPath = getRulesFilePath("project", rulesDir, effectiveCwd);
|
|
49
|
+
|
|
50
|
+
const gResult = listRulesDetail(globalPath);
|
|
51
|
+
if (gResult.rules.length > 0) {
|
|
52
|
+
parts.push("── 全局规则 ──");
|
|
53
|
+
for (const r of gResult.rules) {
|
|
54
|
+
const { index: _, ...data } = r;
|
|
55
|
+
parts.push(`[global:${r.index}] ${JSON.stringify(data, null, "\t")}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const pResult = listRulesDetail(projectPath);
|
|
59
|
+
if (pResult.rules.length > 0) {
|
|
60
|
+
parts.push("── 项目级规则 ──");
|
|
61
|
+
for (const r of pResult.rules) {
|
|
62
|
+
const { index: _, ...data } = r;
|
|
63
|
+
parts.push(`[project:${r.index}] ${JSON.stringify(data, null, "\t")}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (parts.length === 0)
|
|
67
|
+
return textResult("暂无规则(全局和项目级均为空)。");
|
|
68
|
+
return textResult(parts.join("\n\n"));
|
|
69
|
+
}
|
|
70
|
+
// 指定 scope
|
|
71
|
+
const filePath = getRulesFilePath(scope, rulesDir, effectiveCwd);
|
|
72
|
+
const result = listRulesDetail(filePath);
|
|
73
|
+
if (result.error) return textResult(`❌ ${result.error}`);
|
|
74
|
+
if (result.count === 0) return textResult(`暂无${scopeLabel(scope)}规则。`);
|
|
75
|
+
return textResult(
|
|
76
|
+
result.rules
|
|
77
|
+
.map((r) => {
|
|
78
|
+
const { index: _, ...data } = r;
|
|
79
|
+
return `[${scope}:${r.index}] ${JSON.stringify(data, null, "\t")}`;
|
|
80
|
+
})
|
|
81
|
+
.join("\n\n"),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 摘要模式:一行一条,只显示 comment + action + tool + hook */
|
|
86
|
+
export function handleListSummary(
|
|
87
|
+
scope: Scope | undefined,
|
|
88
|
+
rulesDir: string,
|
|
89
|
+
effectiveCwd: string,
|
|
90
|
+
) {
|
|
91
|
+
if (!scope) {
|
|
92
|
+
const items = listRulesByScope(rulesDir, effectiveCwd);
|
|
93
|
+
if (items.length === 0)
|
|
94
|
+
return textResult("暂无规则(全局和项目级均为空)。");
|
|
95
|
+
return textResult(
|
|
96
|
+
items
|
|
97
|
+
.map(
|
|
98
|
+
(r) =>
|
|
99
|
+
`[${r.scope}:${r.index}] ${r.comment}` +
|
|
100
|
+
(r.enabled === false ? " (disabled)" : "") +
|
|
101
|
+
(r.action ? ` — ${r.action}` : "") +
|
|
102
|
+
(r.tool ? ` on ${r.tool}` : "") +
|
|
103
|
+
(r.hook ? ` @ ${r.hook}` : ""),
|
|
104
|
+
)
|
|
105
|
+
.join("\n"),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const filePath = getRulesFilePath(scope, rulesDir, effectiveCwd);
|
|
109
|
+
const result = listRules(filePath);
|
|
110
|
+
if (result.error) return textResult(`❌ ${result.error}`);
|
|
111
|
+
if (result.count === 0) return textResult(`暂无${scopeLabel(scope)}规则。`);
|
|
112
|
+
return textResult(
|
|
113
|
+
result.rules
|
|
114
|
+
.map(
|
|
115
|
+
(r) =>
|
|
116
|
+
`[${scope}:${r.index}] ${r.comment}` +
|
|
117
|
+
(r.enabled === false ? " (disabled)" : "") +
|
|
118
|
+
(r.action ? ` — ${r.action}` : "") +
|
|
119
|
+
(r.tool ? ` on ${r.tool}` : "") +
|
|
120
|
+
(r.hook ? ` @ ${r.hook}` : ""),
|
|
121
|
+
)
|
|
122
|
+
.join("\n"),
|
|
123
|
+
);
|
|
124
|
+
}
|