@zhushanwen/pi-exec-skills 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/package.json +40 -0
- package/src/__tests__/exec-skills.test.ts +449 -0
- package/src/__tests__/resolve-skills.test.ts +99 -0
- package/src/exec-skills.ts +412 -0
- package/src/index.ts +11 -0
- package/src/resolve-skills.ts +65 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-exec-skills",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Shared skill acquisition library for Pi extensions — disk-scanning discovery over pi's four skill roots (settings overrides + plan-exec frontmatter marker + name dedup) and the pi-registry execution gate (resolveSkills). Shared library, not a Pi extension.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"import": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"skills",
|
|
17
|
+
"shared",
|
|
18
|
+
"skill-discovery",
|
|
19
|
+
"execution-gate"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"files": [
|
|
23
|
+
"src/"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@zhushanwen/pi-extension-logger": "0.6.1"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": "^0.84.4"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
33
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
34
|
+
"vitest": "^4.1.8"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"typecheck": "npx tsc --noEmit",
|
|
38
|
+
"test": "vitest run"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
// Mock 共享 logger:降级规格的 warn 留痕可 spy
|
|
8
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
9
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
12
|
+
getLogger: () => loggerMock,
|
|
13
|
+
createLogger: () => loggerMock,
|
|
14
|
+
setPiHandle: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// 对拍锚点直接用 node_modules 实装(pi 0.84.4 dist,AGENTS.md 语义断言约定)——
|
|
18
|
+
// 本测试钉跨根组装 / overrides / marker 过滤 / 降级四段共享包自身语义,另设
|
|
19
|
+
// pi 单根规则快照与对拍等价锚(单根枚举横切规则随 pi 实装走,pi bump 改规则时红灯)
|
|
20
|
+
import { loadSkillsFromDir } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
|
|
22
|
+
import { detectExecSkills, hasPlanExecMarker, isEnabledByOverrides } from "../exec-skills.js";
|
|
23
|
+
import type { ExecSkill } from "../exec-skills.js";
|
|
24
|
+
|
|
25
|
+
/** SKILL.md 内容构造(frontmatter 字段按需) */
|
|
26
|
+
function skillMd(opts: {
|
|
27
|
+
name?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
planExec?: boolean;
|
|
30
|
+
disableModelInvocation?: boolean;
|
|
31
|
+
}): string {
|
|
32
|
+
const lines = ["---"];
|
|
33
|
+
if (opts.name !== undefined) lines.push(`name: ${opts.name}`);
|
|
34
|
+
lines.push(`description: ${opts.description ?? "Execute development workflows."}`);
|
|
35
|
+
if (opts.planExec) lines.push("plan-exec: true");
|
|
36
|
+
if (opts.disableModelInvocation) lines.push("disable-model-invocation: true");
|
|
37
|
+
lines.push("---", "", "# Skill body");
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 四根 fixture 世界:project(.git + 嵌套 cwd + .pi/skills)/ 祖先链 / agentDir / home */
|
|
42
|
+
function makeWorld(): { root: string; cwd: string; agentDir: string; homeDir: string } {
|
|
43
|
+
const root = fs.mkdtempSync(join(tmpdir(), "exec-skills-"));
|
|
44
|
+
const cwd = join(root, "proj", "packages", "lib");
|
|
45
|
+
fs.mkdirSync(join(cwd, ".pi", "skills"), { recursive: true });
|
|
46
|
+
fs.mkdirSync(join(root, "proj", ".git"), { recursive: true });
|
|
47
|
+
fs.mkdirSync(join(root, "proj", "packages", ".agents", "skills"), { recursive: true });
|
|
48
|
+
fs.mkdirSync(join(root, "proj", ".agents", "skills"), { recursive: true });
|
|
49
|
+
const agentDir = join(root, "agent");
|
|
50
|
+
fs.mkdirSync(join(agentDir, "skills"), { recursive: true });
|
|
51
|
+
const homeDir = join(root, "home");
|
|
52
|
+
fs.mkdirSync(join(homeDir, ".agents", "skills"), { recursive: true });
|
|
53
|
+
return { root, cwd, agentDir, homeDir };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 在 dir 下建一个标准 SKILL.md 目录形态 skill(默认带 plan-exec marker) */
|
|
57
|
+
function writeSkill(dir: string, content = skillMd({ planExec: true })): string {
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
const skillPath = join(dir, "SKILL.md");
|
|
60
|
+
fs.writeFileSync(skillPath, content);
|
|
61
|
+
return skillPath;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function rmWorld(root: string): void {
|
|
65
|
+
fs.rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let world: ReturnType<typeof makeWorld>;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.clearAllMocks();
|
|
72
|
+
world = makeWorld();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.unstubAllEnvs();
|
|
77
|
+
rmWorld(world.root);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const detect = (overrides: Partial<Parameters<typeof detectExecSkills>[0]> = {}) =>
|
|
81
|
+
detectExecSkills({
|
|
82
|
+
cwd: world.cwd,
|
|
83
|
+
trusted: true,
|
|
84
|
+
agentDir: world.agentDir,
|
|
85
|
+
homeDir: world.homeDir,
|
|
86
|
+
...overrides,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const namesOf = (skills: ExecSkill[]) => skills.map((s) => s.name);
|
|
90
|
+
|
|
91
|
+
describe("detectExecSkills(发现:四根目录扫描,pi 本体加载集对齐)", () => {
|
|
92
|
+
it("enumerates all four roots in pi order: .pi/skills → 祖先链(近→远) → agentDir → ~/.agents/skills", () => {
|
|
93
|
+
writeSkill(join(world.cwd, ".pi", "skills", "lib-skill"));
|
|
94
|
+
writeSkill(join(world.root, "proj", "packages", ".agents", "skills", "mid-skill"));
|
|
95
|
+
writeSkill(join(world.root, "proj", ".agents", "skills", "root-skill"));
|
|
96
|
+
writeSkill(join(world.agentDir, "skills", "agent-skill"));
|
|
97
|
+
writeSkill(join(world.homeDir, ".agents", "skills", "home-skill"));
|
|
98
|
+
|
|
99
|
+
const skills = detect();
|
|
100
|
+
expect(namesOf(skills)).toEqual(["lib-skill", "mid-skill", "root-skill", "agent-skill", "home-skill"]);
|
|
101
|
+
// skillEntryPath 数据通路:消费方指引的路径来源 = 入口文件路径(标准形态即 SKILL.md 路径)
|
|
102
|
+
expect(skills[0].skillEntryPath).toBe(join(world.cwd, ".pi", "skills", "lib-skill", "SKILL.md"));
|
|
103
|
+
expect(skills[0].description).toBe("Execute development workflows.");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("untrusted skips the project family (.pi/skills + ancestor chain), still scans agentDir/home roots", () => {
|
|
107
|
+
writeSkill(join(world.cwd, ".pi", "skills", "lib-skill"));
|
|
108
|
+
writeSkill(join(world.root, "proj", ".agents", "skills", "root-skill"));
|
|
109
|
+
writeSkill(join(world.agentDir, "skills", "agent-skill"));
|
|
110
|
+
|
|
111
|
+
const skills = detect({ trusted: false });
|
|
112
|
+
expect(namesOf(skills)).toEqual(["agent-skill"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("first-writer-wins: an earlier root occupies the name even without the marker", () => {
|
|
116
|
+
// pi 的 collision 语义以加载集为准——前根占名后根不让位(含 marker 缺席的占名)
|
|
117
|
+
writeSkill(join(world.cwd, ".pi", "skills", "dup"), skillMd({ planExec: false }));
|
|
118
|
+
writeSkill(join(world.agentDir, "skills", "dup"));
|
|
119
|
+
|
|
120
|
+
expect(namesOf(detect())).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("user settings '-' disable removes the skill across roots; '+' force-include wins over '!'", () => {
|
|
124
|
+
writeSkill(join(world.agentDir, "skills", "agent-skill"));
|
|
125
|
+
writeSkill(join(world.homeDir, ".agents", "skills", "home-skill"));
|
|
126
|
+
fs.writeFileSync(
|
|
127
|
+
join(world.agentDir, "settings.json"),
|
|
128
|
+
JSON.stringify({ skills: ["!agent-skill", "home-skill", "+home-skill"] }),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// '-' 强排除最优先:agent-skill 被禁;home-skill 经 '!' 禁止后又被 '+' 强制包含
|
|
132
|
+
expect(namesOf(detect())).toEqual(["home-skill"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("project settings overrides apply only when trusted (untrusted project settings 恒不参与)", () => {
|
|
136
|
+
writeSkill(join(world.cwd, ".pi", "skills", "lib-skill"));
|
|
137
|
+
fs.writeFileSync(
|
|
138
|
+
join(world.cwd, ".pi", "settings.json"),
|
|
139
|
+
// '!' glob 前缀 pattern:靠 SKILL.md 父目录名命中
|
|
140
|
+
JSON.stringify({ skills: ["!lib-skill"] }),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(namesOf(detect({ trusted: true }))).toEqual([]);
|
|
144
|
+
expect(namesOf(detect({ trusted: false }))).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("glob disable pattern matches by parent dir name for SKILL.md entries", () => {
|
|
148
|
+
writeSkill(join(world.agentDir, "skills", "dev-flow"));
|
|
149
|
+
fs.writeFileSync(
|
|
150
|
+
join(world.agentDir, "settings.json"),
|
|
151
|
+
JSON.stringify({ skills: ["!dev-*"] }),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(namesOf(detect())).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("degrades per-item: broken YAML frontmatter skips that skill without throwing, siblings survive", () => {
|
|
158
|
+
// 非法 YAML:pi loadSkillFromFile 解析失败 → 该 skill 不进加载集(检测侧只见空位)
|
|
159
|
+
writeSkill(join(world.agentDir, "skills", "bad"), "---\nname: [unclosed\ndescription: x\n---\n# body\n");
|
|
160
|
+
writeSkill(join(world.agentDir, "skills", "good"));
|
|
161
|
+
|
|
162
|
+
expect(namesOf(detect())).toEqual(["good"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("degrades per-root: EACCES root is skipped without throwing, other roots still detected", () => {
|
|
166
|
+
writeSkill(join(world.cwd, ".pi", "skills", "blocked-skill"));
|
|
167
|
+
writeSkill(join(world.homeDir, ".agents", "skills", "home-skill"));
|
|
168
|
+
fs.chmodSync(join(world.cwd, ".pi", "skills"), 0o000);
|
|
169
|
+
try {
|
|
170
|
+
// pi loadSkillsFromDirInternal 内部吞掉 readdir 错误返回空集(外层 try/catch 是纵深防御)
|
|
171
|
+
expect(namesOf(detect())).toEqual(["home-skill"]);
|
|
172
|
+
} finally {
|
|
173
|
+
fs.chmodSync(join(world.cwd, ".pi", "skills"), 0o755);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("degrades per-root: settings parse failure is treated as no overrides with a warn", () => {
|
|
178
|
+
writeSkill(join(world.agentDir, "skills", "agent-skill"));
|
|
179
|
+
fs.writeFileSync(join(world.agentDir, "settings.json"), "{ not json");
|
|
180
|
+
|
|
181
|
+
const skills = detect();
|
|
182
|
+
expect(namesOf(skills)).toEqual(["agent-skill"]);
|
|
183
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
184
|
+
"exec-skills: settings parse failed (treated as no overrides)",
|
|
185
|
+
expect.objectContaining({ path: join(world.agentDir, "settings.json") }),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("never throws: a cwd that vanishes mid-detection degrades to the empty set", () => {
|
|
190
|
+
// 整体降级规格:任何 fs 错 → 空集 + warn,绝不向消费方抛
|
|
191
|
+
const missingDir = join(world.root, "removed", "cwd");
|
|
192
|
+
const skills = detect({ cwd: missingDir });
|
|
193
|
+
expect(skills).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("missing roots are a legal default (no warn): fresh machines scan cleanly", () => {
|
|
197
|
+
// 空世界(各根目录存在但无 skill;agentDir/settings 缺失是常态主路径)
|
|
198
|
+
expect(detect()).toEqual([]);
|
|
199
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("HOME env injection: default homeDir derives from process.env.HOME (pi getHomeDir 同式)", () => {
|
|
203
|
+
vi.stubEnv("HOME", world.homeDir);
|
|
204
|
+
writeSkill(join(world.homeDir, ".agents", "skills", "home-skill"));
|
|
205
|
+
|
|
206
|
+
const skills = detectExecSkills({ cwd: world.cwd, trusted: true, agentDir: world.agentDir });
|
|
207
|
+
expect(namesOf(skills)).toEqual(["home-skill"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("ancestor chain stops at a .git FILE (worktree 双语义), skills beyond the root are not proposed", () => {
|
|
211
|
+
// 独立世界:worktree 形态 .git 文件 + 链外根
|
|
212
|
+
const root = fs.mkdtempSync(join(tmpdir(), "exec-skills-wt-"));
|
|
213
|
+
try {
|
|
214
|
+
fs.mkdirSync(join(root, "wt"), { recursive: true });
|
|
215
|
+
fs.writeFileSync(join(root, "wt", ".git"), "gitdir: /elsewhere\n");
|
|
216
|
+
const cwd = join(root, "wt", "work");
|
|
217
|
+
writeSkill(join(cwd, ".agents", "skills", "inside-skill"));
|
|
218
|
+
writeSkill(join(root, ".agents", "skills", "outside-skill"));
|
|
219
|
+
const skills = detectExecSkills({ cwd, trusted: true, agentDir: join(root, "empty-agent"), homeDir: join(root, "empty-home") });
|
|
220
|
+
expect(namesOf(skills)).toEqual(["inside-skill"]);
|
|
221
|
+
} finally {
|
|
222
|
+
rmWorld(root);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("cwd 在 HOME 下时祖先链与 ~/.agents/skills 的同路径项被滤掉(pi :1979 同款,不重复)", () => {
|
|
227
|
+
// 独立世界:home 即 git root,cwd 在其下——链会经过 home/.agents/skills(与第四根同路径)
|
|
228
|
+
const root = fs.mkdtempSync(join(tmpdir(), "exec-skills-dedup-"));
|
|
229
|
+
try {
|
|
230
|
+
const homeDir = join(root, "home");
|
|
231
|
+
fs.mkdirSync(join(homeDir, ".git"), { recursive: true });
|
|
232
|
+
const cwd = join(homeDir, "proj");
|
|
233
|
+
writeSkill(join(homeDir, ".agents", "skills", "home-skill"));
|
|
234
|
+
const skills = detectExecSkills({ cwd, trusted: true, agentDir: join(root, "empty-agent"), homeDir });
|
|
235
|
+
expect(namesOf(skills)).toEqual(["home-skill"]); // 恰一次
|
|
236
|
+
} finally {
|
|
237
|
+
rmWorld(root);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("symlinked duplicate resolves to a single entry(realPath 去重)", () => {
|
|
242
|
+
const realSkillPath = writeSkill(join(world.cwd, ".pi", "skills", "dup"));
|
|
243
|
+
fs.symlinkSync(join(world.cwd, ".pi", "skills", "dup"), join(world.homeDir, ".agents", "skills", "alias"));
|
|
244
|
+
const skills = detect();
|
|
245
|
+
expect(namesOf(skills)).toEqual(["dup"]);
|
|
246
|
+
expect(skills[0].skillEntryPath).toBe(realSkillPath); // 入口文件路径(realPath 去重后指向真身)
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("plain patterns without +/-/! prefix are ignored for auto resources(pi getOverridePatterns 同款)", () => {
|
|
250
|
+
writeSkill(join(world.cwd, ".pi", "skills", "dev-flow"));
|
|
251
|
+
fs.writeFileSync(join(world.cwd, ".pi", "settings.json"), JSON.stringify({ skills: ["dev-flow"] }));
|
|
252
|
+
expect(namesOf(detect())).toEqual(["dev-flow"]);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("detectExecSkills(marker 过滤:plan-exec frontmatter 标记)", () => {
|
|
257
|
+
it("filters to skills carrying the plan-exec marker", () => {
|
|
258
|
+
writeSkill(join(world.agentDir, "skills", "marked"), skillMd({ planExec: true }));
|
|
259
|
+
writeSkill(join(world.agentDir, "skills", "plain"), skillMd({ planExec: false }));
|
|
260
|
+
|
|
261
|
+
expect(namesOf(detect())).toEqual(["marked"]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("disable-model-invocation coexists with the marker without filtering (user explicit choice is another channel)", () => {
|
|
265
|
+
writeSkill(
|
|
266
|
+
join(world.agentDir, "skills", "manual-only"),
|
|
267
|
+
skillMd({ planExec: true, disableModelInvocation: true }),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(namesOf(detect())).toEqual(["manual-only"]);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("hasPlanExecMarker(二次读 frontmatter)", () => {
|
|
275
|
+
it("accepts only the strict boolean true", () => {
|
|
276
|
+
const strictTrue = join(world.root, "strict.md");
|
|
277
|
+
const stringTrue = join(world.root, "string.md");
|
|
278
|
+
const absent = join(world.root, "absent.md");
|
|
279
|
+
fs.writeFileSync(strictTrue, "---\nplan-exec: true\n---\nbody");
|
|
280
|
+
fs.writeFileSync(stringTrue, "---\nplan-exec: \"true\"\n---\nbody");
|
|
281
|
+
fs.writeFileSync(absent, "---\ndescription: x\n---\nbody");
|
|
282
|
+
|
|
283
|
+
expect(hasPlanExecMarker(strictTrue)).toBe(true);
|
|
284
|
+
expect(hasPlanExecMarker(stringTrue)).toBe(false);
|
|
285
|
+
expect(hasPlanExecMarker(absent)).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("read failure degrades to false with a warn (skill skipped)", () => {
|
|
289
|
+
const missing = join(world.root, "missing.md");
|
|
290
|
+
expect(hasPlanExecMarker(missing)).toBe(false);
|
|
291
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
292
|
+
"exec-skills: frontmatter read failed (skill skipped)",
|
|
293
|
+
expect.objectContaining({ path: missing }),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("isEnabledByOverrides(overrides 三级优先级,pi 同构)", () => {
|
|
299
|
+
const file = "/base/skills/demo/SKILL.md";
|
|
300
|
+
|
|
301
|
+
it("default enabled when no override matches", () => {
|
|
302
|
+
expect(isEnabledByOverrides(file, [], "/base")).toBe(true);
|
|
303
|
+
expect(isEnabledByOverrides(file, ["other-*"], "/base")).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("'!' glob exclude disables, '+' exact force-include re-enables over '!'", () => {
|
|
307
|
+
expect(isEnabledByOverrides(file, ["!demo"], "/base")).toBe(false);
|
|
308
|
+
expect(isEnabledByOverrides(file, ["!demo", "+skills/demo/SKILL.md"], "/base")).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("'-' exact force-exclude has the final say over '+' and '!'", () => {
|
|
312
|
+
expect(isEnabledByOverrides(file, ["!demo", "+demo", "-demo"], "/base")).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("relative-path form hits SKILL.md excludes via the rel candidate", () => {
|
|
316
|
+
expect(isEnabledByOverrides(file, ["!skills/demo/SKILL.md"], "/base")).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("glob 跨形态命中(parentName/rel glob/跨目录 glob)", () => {
|
|
320
|
+
const baseDir = join(world.root, "base");
|
|
321
|
+
const filePath = join(baseDir, "skills", "demo", "SKILL.md");
|
|
322
|
+
expect(isEnabledByOverrides(filePath, ["!demo"], baseDir)).toBe(false); // parentName
|
|
323
|
+
expect(isEnabledByOverrides(filePath, ["!skills/demo/*"], baseDir)).toBe(false); // rel glob
|
|
324
|
+
expect(isEnabledByOverrides(filePath, ["!**/SKILL.md"], baseDir)).toBe(false); // 跨目录 glob
|
|
325
|
+
expect(isEnabledByOverrides(filePath, ["!other"], baseDir)).toBe(true); // 未命中默认启用
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// globToRegExp 未测分支(minimatch 子集重实现,与 pi 求值的偏差域):? 单字符 /
|
|
329
|
+
// [...] 字符类(含取反)/ 未闭合 ] 字面量 / 正则元字符转义——经公开入口锁语义,
|
|
330
|
+
// 任一分支回归即红(false = 禁用命中,true = 默认启用未命中)。
|
|
331
|
+
it("? / 字符类 / 取反 / 未闭合 ] / 元字符转义分支", () => {
|
|
332
|
+
const baseDir = join(world.root, "base");
|
|
333
|
+
const filePath = join(baseDir, "skills", "demo", "SKILL.md");
|
|
334
|
+
// ? = 单字符且不跨 /:dem? 命中 o;demo? 差一字符不命中;skills?demo 不跨 / 不命中
|
|
335
|
+
expect(isEnabledByOverrides(filePath, ["!skills/dem?/SKILL.md"], baseDir)).toBe(false);
|
|
336
|
+
expect(isEnabledByOverrides(filePath, ["!skills/demo?/SKILL.md"], baseDir)).toBe(true);
|
|
337
|
+
expect(isEnabledByOverrides(filePath, ["!skills?demo/SKILL.md"], baseDir)).toBe(true);
|
|
338
|
+
// 字符类:[a-z] 命中 d;[0-9] 未命中
|
|
339
|
+
expect(isEnabledByOverrides(filePath, ["!skills/[a-z]emo/SKILL.md"], baseDir)).toBe(false);
|
|
340
|
+
expect(isEnabledByOverrides(filePath, ["!skills/[0-9]emo/SKILL.md"], baseDir)).toBe(true);
|
|
341
|
+
// 取反类:[!a-z] 排除小写字母 → d 不命中;[!0-9] 排除数字 → d 命中
|
|
342
|
+
expect(isEnabledByOverrides(filePath, ["!skills/[!a-z]emo/SKILL.md"], baseDir)).toBe(true);
|
|
343
|
+
expect(isEnabledByOverrides(filePath, ["!skills/[!0-9]emo/SKILL.md"], baseDir)).toBe(false);
|
|
344
|
+
// 未闭合 ]:[ 按字面量处理(不吞后续段),demo 目录无 [ → 不命中
|
|
345
|
+
expect(isEnabledByOverrides(filePath, ["!skills/demo[/SKILL.md"], baseDir)).toBe(true);
|
|
346
|
+
// 字符类单字:dem[o] 命中(dem 后是 o);dem[w] 不命中(dem 后非 w)
|
|
347
|
+
expect(isEnabledByOverrides(filePath, ["!skills/dem[o]/SKILL.md"], baseDir)).toBe(false);
|
|
348
|
+
expect(isEnabledByOverrides(filePath, ["!skills/dem[w]/SKILL.md"], baseDir)).toBe(true);
|
|
349
|
+
// 正则元字符转义:. 与 $ 不提前终止/锚定匹配——dem.o(字面点)不命中 demo,
|
|
350
|
+
// demo$(字面 $)不命中;若未转义 . 会通配命中、$ 会锚定改变语义
|
|
351
|
+
expect(isEnabledByOverrides(filePath, ["!skills/dem.o/SKILL.md"], baseDir)).toBe(true);
|
|
352
|
+
expect(isEnabledByOverrides(filePath, ["!skills/demo$/SKILL.md"], baseDir)).toBe(true);
|
|
353
|
+
expect(isEnabledByOverrides(filePath, ["!skills/demo/SKILL.md"], baseDir)).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// 单根枚举横切规则的 pi 实装对拍:同 fixture 下共享包跨根结果 ⊆ pi 单根加载集;
|
|
358
|
+
// 单根规则快照锁 pi 实装语义(ignore/description 门/name 派生/递归/node_modules/隐藏文件)
|
|
359
|
+
describe("pi 实装对拍(loadSkillsFromDir 语义锚)", () => {
|
|
360
|
+
it("loadSkillsFromDir derives the skill name from frontmatter over dir name", () => {
|
|
361
|
+
writeSkill(join(world.agentDir, "skills", "dir-name"), skillMd({ name: "front-name", planExec: true }));
|
|
362
|
+
|
|
363
|
+
const loaded = loadSkillsFromDir({ dir: join(world.agentDir, "skills"), source: "detect" }).skills;
|
|
364
|
+
expect(loaded.map((s) => s.name)).toEqual(["front-name"]);
|
|
365
|
+
expect(namesOf(detect())).toEqual(["front-name"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 单根 fixture(挂在 agentDir/skills,其余根不存在):
|
|
370
|
+
* - alpha:frontmatter name 覆盖目录名(⑥ name 派生)
|
|
371
|
+
* - beta:无 frontmatter name → 目录名 fallback(⑥)
|
|
372
|
+
* - no-desc:无 description → ③ 必填门排除
|
|
373
|
+
* - nested/deep/gamma:递归遍历
|
|
374
|
+
* - loose.md:根级散 .md(loadSkillsFromDir 收集,⑦登记面内)
|
|
375
|
+
* - ignored/:.gitignore 排除(①)
|
|
376
|
+
* - node_modules 与 .hidden:loader 内建排除
|
|
377
|
+
*/
|
|
378
|
+
function makeAnchorRoot(): { root: string; agentDir: string } {
|
|
379
|
+
const root = fs.mkdtempSync(join(tmpdir(), "exec-skills-anchor-"));
|
|
380
|
+
const skillsRoot = join(root, "agent", "skills");
|
|
381
|
+
fs.mkdirSync(skillsRoot, { recursive: true });
|
|
382
|
+
writeSkill(join(skillsRoot, "alpha"), skillMd({ name: "custom-alpha", planExec: true }));
|
|
383
|
+
writeSkill(join(skillsRoot, "beta"), skillMd({ planExec: true }));
|
|
384
|
+
writeSkill(join(skillsRoot, "no-desc"), "---\nname: no-desc\n---\n\n# no description\n");
|
|
385
|
+
writeSkill(join(skillsRoot, "nested", "deep", "gamma"), skillMd({ planExec: true }));
|
|
386
|
+
// 根级散 .md(loadSkillsFromDir 收集形态;name 走 frontmatter——散文件 parentDir 名不可用)
|
|
387
|
+
fs.writeFileSync(join(skillsRoot, "loose.md"), skillMd({ name: "loose", planExec: true }));
|
|
388
|
+
writeSkill(join(skillsRoot, "ignored"), skillMd({ planExec: true }));
|
|
389
|
+
writeSkill(join(skillsRoot, "node_modules", "pkg"), skillMd({ planExec: true }));
|
|
390
|
+
writeSkill(join(skillsRoot, ".hidden"), skillMd({ planExec: true }));
|
|
391
|
+
fs.writeFileSync(join(skillsRoot, ".gitignore"), "ignored/\n");
|
|
392
|
+
return { root, agentDir: join(root, "agent") };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
it("pi loadSkillsFromDir anchor:单根规则快照(①ignore/③description 门/⑥name 派生/递归/node_modules/隐藏文件)", () => {
|
|
396
|
+
const { root, agentDir } = makeAnchorRoot();
|
|
397
|
+
try {
|
|
398
|
+
const piResult = loadSkillsFromDir({ dir: join(agentDir, "skills"), source: "anchor" });
|
|
399
|
+
// pi 实装语义快照——pi bump 改单根规则时此处红灯(守卫边界 = 单根规则漂移)
|
|
400
|
+
expect(piResult.skills.map((s) => s.name).sort()).toEqual(["beta", "custom-alpha", "gamma", "loose"]);
|
|
401
|
+
expect(piResult.skills.some((s) => s.name === "no-desc")).toBe(false); // ③ description 必填门
|
|
402
|
+
expect(piResult.skills.some((s) => s.filePath.includes("ignored"))).toBe(false); // ① gitignore
|
|
403
|
+
expect(piResult.skills.some((s) => s.filePath.includes("node_modules"))).toBe(false);
|
|
404
|
+
expect(piResult.skills.some((s) => s.filePath.includes(".hidden"))).toBe(false);
|
|
405
|
+
} finally {
|
|
406
|
+
rmWorld(root);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("detection per-root enumeration equals pi loadSkillsFromDir on the same fixture(对拍等价)", () => {
|
|
411
|
+
const { root, agentDir } = makeAnchorRoot();
|
|
412
|
+
try {
|
|
413
|
+
const piNames = loadSkillsFromDir({ dir: join(agentDir, "skills"), source: "anchor" })
|
|
414
|
+
.skills.map((s) => s.name).sort();
|
|
415
|
+
const detected = detectExecSkills({
|
|
416
|
+
cwd: join(root, "empty-cwd"),
|
|
417
|
+
trusted: true,
|
|
418
|
+
agentDir,
|
|
419
|
+
homeDir: join(root, "empty-home"),
|
|
420
|
+
});
|
|
421
|
+
// marker 过滤前的 pi 加载集 = 检测的单根并集;marker 只减不增
|
|
422
|
+
expect(piNames).toContain("custom-alpha"); // ⑥ frontmatter name
|
|
423
|
+
expect(piNames).toContain("beta");
|
|
424
|
+
expect(namesOf(detected).sort()).toEqual(["beta", "custom-alpha", "gamma", "loose"]);
|
|
425
|
+
// ⑥:消费方指引名与 pi prompt 列表一致(frontmatter name 而非目录名)
|
|
426
|
+
expect(detected.some((s) => s.name === "custom-alpha" && s.skillEntryPath.endsWith(join("alpha", "SKILL.md")))).toBe(true);
|
|
427
|
+
expect(detected.some((s) => s.name === "alpha")).toBe(false);
|
|
428
|
+
} finally {
|
|
429
|
+
rmWorld(root);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("散 .md 形态:skillEntryPath = 文件路径本身(指引路径不悬空,⑦登记面内)", () => {
|
|
434
|
+
const { root, agentDir } = makeAnchorRoot();
|
|
435
|
+
try {
|
|
436
|
+
const detected = detectExecSkills({
|
|
437
|
+
cwd: join(root, "empty-cwd"),
|
|
438
|
+
trusted: true,
|
|
439
|
+
agentDir,
|
|
440
|
+
homeDir: join(root, "empty-home"),
|
|
441
|
+
});
|
|
442
|
+
const loose = detected.find((s) => s.name === "loose");
|
|
443
|
+
// 入口路径 = loose.md 文件本身——不是 `<skills根>/SKILL.md`(散 .md 形态拼 SKILL.md 必然悬空)
|
|
444
|
+
expect(loose?.skillEntryPath).toBe(join(agentDir, "skills", "loose.md"));
|
|
445
|
+
} finally {
|
|
446
|
+
rmWorld(root);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { normalizeSkillName, resolveSkills } from "../resolve-skills.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* fake skill 命令条目(SlashCommandInfo 形态:source === 'skill' + sourceInfo.path)。
|
|
9
|
+
* name 带 pi 命名空间前缀(`skill:<name>`)——真实 pi.getCommands() 枚举 skill 类
|
|
10
|
+
* 命令即此形态,归一化比对的靶子。
|
|
11
|
+
*/
|
|
12
|
+
const SKILL_COMMANDS = [
|
|
13
|
+
{ name: "skill:tech-design", source: "skill", sourceInfo: { path: "/skills/tech-design/SKILL.md" } },
|
|
14
|
+
{ name: "skill:dev-flow", source: "skill", sourceInfo: { path: "/skills/dev-flow/SKILL.md" } },
|
|
15
|
+
{ name: "skill:code review", source: "skill", sourceInfo: { path: "/skills/code review/SKILL.md" } },
|
|
16
|
+
{ name: "plan", source: "extension", sourceInfo: { path: "/extensions/universal/plan" } },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const makePi = (commands: unknown[] = SKILL_COMMANDS) =>
|
|
20
|
+
({ getCommands: vi.fn(() => commands) }) as unknown as ExtensionAPI;
|
|
21
|
+
|
|
22
|
+
describe("normalizeSkillName(skill: 命名空间前缀剥离)", () => {
|
|
23
|
+
it("strips the leading 'skill:' namespace prefix once", () => {
|
|
24
|
+
expect(normalizeSkillName("skill:tech-design")).toBe("tech-design");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("leaves bare names untouched", () => {
|
|
28
|
+
expect(normalizeSkillName("tech-design")).toBe("tech-design");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("only strips the prefix form, not a bare name that merely contains it", () => {
|
|
32
|
+
// skill: 前缀是命名空间标记,非子串替换——「xskill:y」的自然名不是「xy」
|
|
33
|
+
expect(normalizeSkillName("xskill:y")).toBe("xskill:y");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("resolveSkills(执行门禁:pi 注册表枚举比对,双向剥 skill: 前缀归一)", () => {
|
|
38
|
+
it("resolves prefixed enum entries from bare-name input (short name in resolved)", () => {
|
|
39
|
+
// 无前缀输入命中带前缀枚举(输入面:用户用自然技能名)
|
|
40
|
+
const resolution = resolveSkills(makePi(), ["tech-design", "code review"]);
|
|
41
|
+
expect(resolution.ok).toBe(true);
|
|
42
|
+
if (resolution.ok) {
|
|
43
|
+
expect(resolution.resolved).toEqual([
|
|
44
|
+
{ name: "tech-design", skillPath: "/skills/tech-design/SKILL.md" },
|
|
45
|
+
{ name: "code review", skillPath: "/skills/code review/SKILL.md" },
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("resolves explicit 'skill:'-prefixed input too (both directions normalize)", () => {
|
|
51
|
+
const resolution = resolveSkills(makePi(), ["skill:tech-design", "skill:dev-flow"]);
|
|
52
|
+
expect(resolution.ok).toBe(true);
|
|
53
|
+
if (resolution.ok) {
|
|
54
|
+
// resolved 用归一后的短名(无前缀)
|
|
55
|
+
expect(resolution.resolved).toEqual([
|
|
56
|
+
{ name: "tech-design", skillPath: "/skills/tech-design/SKILL.md" },
|
|
57
|
+
{ name: "dev-flow", skillPath: "/skills/dev-flow/SKILL.md" },
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("only compares entries with source === 'skill'", () => {
|
|
63
|
+
// 非 skill source 的命令(extension 等)不参与门禁——「plan」不被当作技能放行
|
|
64
|
+
const resolution = resolveSkills(makePi(), ["plan"]);
|
|
65
|
+
expect(resolution.ok).toBe(false);
|
|
66
|
+
if (!resolution.ok) {
|
|
67
|
+
expect(resolution.missing).toEqual(["plan"]);
|
|
68
|
+
// available 维持枚举原形态(带前缀,错误信息里可直接复制为 pi 命令)
|
|
69
|
+
expect(resolution.available).toEqual(["skill:tech-design", "skill:dev-flow", "skill:code review"]);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("unknown skill reports missing together with the available list", () => {
|
|
74
|
+
const resolution = resolveSkills(makePi(), ["tech-design", "tech-desig"]);
|
|
75
|
+
expect(resolution.ok).toBe(false);
|
|
76
|
+
if (!resolution.ok) {
|
|
77
|
+
expect(resolution.missing).toEqual(["tech-desig"]);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("prefixed-but-unknown input reports the normalized short name as missing", () => {
|
|
82
|
+
const resolution = resolveSkills(makePi(), ["skill:tech-desig"]);
|
|
83
|
+
expect(resolution.ok).toBe(false);
|
|
84
|
+
if (!resolution.ok) {
|
|
85
|
+
expect(resolution.missing).toEqual(["tech-desig"]);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("empty request = 同族 fail-fast:ok:false + 全量 available + 空 missing(报错器的恢复动作输入)", () => {
|
|
90
|
+
// 「--skills 给了但没给名字」:missing 空 → 报错器走「no skill names followed it」
|
|
91
|
+
// 文案分支;available 同源枚举供报错文案直接列出可复制命令
|
|
92
|
+
const resolution = resolveSkills(makePi(), []);
|
|
93
|
+
expect(resolution.ok).toBe(false);
|
|
94
|
+
if (!resolution.ok) {
|
|
95
|
+
expect(resolution.missing).toEqual([]);
|
|
96
|
+
expect(resolution.available).toEqual(["skill:tech-design", "skill:dev-flow", "skill:code review"]);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CONFIG_DIR_NAME,
|
|
7
|
+
getAgentDir,
|
|
8
|
+
loadSkillsFromDir,
|
|
9
|
+
parseFrontmatter,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { Skill } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
13
|
+
|
|
14
|
+
const logger = getLogger("pi-exec-skills");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 技能目录扫描共享能力(ADR-0074 落地包):项目内一切「发现可执行 skill」的磁盘
|
|
18
|
+
* 扫描单源,禁止各扩展自扫目录或直用 pi 注册表快照形成第二实现(pi 的
|
|
19
|
+
* getCommands 返回 resource loader 启动加载集——reload 才刷新的滞后快照)。
|
|
20
|
+
*
|
|
21
|
+
* 与 ./resolve-skills.js 的两段消费语义闭环(ADR-0074):
|
|
22
|
+
* - 本模块 = 「发现」:自研扫描直读磁盘,技能安装即时可见;用于向用户提议
|
|
23
|
+
* (表单列出可用执行方式)。
|
|
24
|
+
* - resolve-skills = 「执行门禁」:注入前经 pi 注册表确认 pi 实际认得该技能。
|
|
25
|
+
*
|
|
26
|
+
* 检测语义 = 「向用户提议的执行 skill」,以 pi 本体加载集为基线做意图/信任对齐:
|
|
27
|
+
* - 单根枚举复用 pi 公开导出 loadSkillsFromDir——单根内横切规则(ignore / description
|
|
28
|
+
* 必填门 / name 派生 / 递归遍历 / symlink / node_modules / 隐藏文件)随 pi 升级
|
|
29
|
+
* 自动同步,零漂移面(对照表①③⑥⑦ 的 SKILL.md 目录形态侧);
|
|
30
|
+
* - 跨根组装自实现(pi 的四根拼装逻辑未导出,对照表②④⑤):根序四枚按 pi 本体
|
|
31
|
+
* `package-manager.js addAutoDiscoveredResources`——`.pi/skills` → 祖先链
|
|
32
|
+
* `.agents/skills`(近→远,git root 级含)→ `<agentDir>/skills` → `~/.agents/skills`
|
|
33
|
+
* (HOME 直取,dev-flow 等用户级 skill 标准安装位);untrusted 跳过前两族(④);
|
|
34
|
+
* settings overrides 的 `+/-/!` 三级优先级 disable 复刻(②);同名 first-writer-wins
|
|
35
|
+
* + realPath 去重(⑤);
|
|
36
|
+
* - plan-exec marker 过滤 = 对过 description 门的 skill 二次读 frontmatter
|
|
37
|
+
* (`plan-exec: true`;pi 的 Skill 对象不保留原始 frontmatter)。skill 侧自愿
|
|
38
|
+
* opt-in,`disable-model-invocation` 与 plan-exec 并存不过滤(用户显式选择是
|
|
39
|
+
* 另一通路,设计 D10 顺手裁决)。
|
|
40
|
+
* - steer 执行走路径读取(read `<skillEntryPath>` = skill 入口文件路径),不依赖 pi skill 注册。
|
|
41
|
+
*
|
|
42
|
+
* 已登记偏差(对照表⑦散 .md 形态):与 pi auto 源(collectSkillEntries)方向相反——
|
|
43
|
+
* agents 源根级散 .md 检测收而 pi 不收(多提议)、子目录散 .md pi 收而检测不收
|
|
44
|
+
* (漏提议);`.pi/skills` 源两侧同构。settings npm 源不扫(现无 npm 发布形态
|
|
45
|
+
* plan-exec skill,出现时补第五根)。
|
|
46
|
+
*
|
|
47
|
+
* 降级规格(检测失败最坏后果 = skill 选项空集,绝不允许炸消费方的核心交互闭环;
|
|
48
|
+
* pi 对坏 skill 也只产 diagnostics 不中断加载):每根 existsSync 守卫 + 整根
|
|
49
|
+
* try/catch(EACCES 等任何 fs 错 → 跳过该根 + warn);单 skill frontmatter 读/解析
|
|
50
|
+
* 失败 → 跳过该项 + warn;settings 读取/解析失败 → 视为无 overrides。
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/** 降级留痕(warn 级):缺省走 pi-extension-logger,测试注入收集器 */
|
|
54
|
+
type LogFn = (msg: string, detail?: object) => void;
|
|
55
|
+
|
|
56
|
+
function defaultLog(msg: string, detail?: object): void {
|
|
57
|
+
logger.warn(msg, detail);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** pi getHomeDir 同式(package-manager.js:78):HOME 优先,缺省 os.homedir() */
|
|
61
|
+
function defaultHomeDir(): string {
|
|
62
|
+
return process.env.HOME || os.homedir();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** pi canonicalizePath 同式:realpath 失败回退原路径 */
|
|
66
|
+
function canonicalizePath(p: string): string {
|
|
67
|
+
try {
|
|
68
|
+
return fs.realpathSync(p);
|
|
69
|
+
} catch {
|
|
70
|
+
return p;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toPosixPath(p: string): string {
|
|
75
|
+
// 与 pi toPosixPath 同构:Windows 分隔符归一(POSIX 上恒等)
|
|
76
|
+
return sep === "\\" ? p.split("\\").join("/") : p;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
80
|
+
return typeof value === "object" && value !== null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** pi findGitRepoRoot 同式:.git 目录或 .git 文件(worktree)均算 root;无 git 推到文件系统根 */
|
|
84
|
+
function findGitRepoRoot(startDir: string): string | null {
|
|
85
|
+
let dir = resolve(startDir);
|
|
86
|
+
while (true) {
|
|
87
|
+
if (fs.existsSync(join(dir, ".git"))) return dir;
|
|
88
|
+
const parent = dirname(dir);
|
|
89
|
+
if (parent === dir) return null;
|
|
90
|
+
dir = parent;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** pi collectAncestorAgentsSkillDirs 同式:startDir 起每级 `.agents/skills`,近→远,git root 级含 */
|
|
95
|
+
function collectAncestorAgentsSkillDirs(startDir: string): string[] {
|
|
96
|
+
const skillDirs: string[] = [];
|
|
97
|
+
const resolvedStartDir = resolve(startDir);
|
|
98
|
+
const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
|
|
99
|
+
let dir = resolvedStartDir;
|
|
100
|
+
while (true) {
|
|
101
|
+
skillDirs.push(join(dir, ".agents", "skills"));
|
|
102
|
+
if (gitRepoRoot && dir === gitRepoRoot) break;
|
|
103
|
+
const parent = dirname(dir);
|
|
104
|
+
if (parent === dir) break;
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
return skillDirs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** BOM 剥离(pi stripBom 同构):带 BOM 的 settings.json 直接 JSON.parse 会 throw */
|
|
111
|
+
function stripBom(text: string): string {
|
|
112
|
+
return text.replace(/^\uFEFF/, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 读 settings.json 的 skills overrides(`+/-/!` pattern 数组)。
|
|
117
|
+
* 读取/解析失败 → [] + warn(pi tryLoadFromStorage 同折叠:错误不中断加载)。
|
|
118
|
+
* pi 旧形态 skills 为对象(customDirectories)——其条目无 `+/-/!` 前缀,对 override
|
|
119
|
+
* 匹配等价于无,此处按非数组折叠为 [] 即可。
|
|
120
|
+
*/
|
|
121
|
+
function readSkillsOverrides(settingsPath: string, log: LogFn): string[] {
|
|
122
|
+
try {
|
|
123
|
+
if (!fs.existsSync(settingsPath)) return [];
|
|
124
|
+
const parsed: unknown = JSON.parse(stripBom(fs.readFileSync(settingsPath, "utf-8")));
|
|
125
|
+
if (!isRecord(parsed)) return [];
|
|
126
|
+
const skills: unknown = parsed.skills;
|
|
127
|
+
if (!Array.isArray(skills)) return [];
|
|
128
|
+
return skills.filter((p): p is string => typeof p === "string");
|
|
129
|
+
} catch (error) {
|
|
130
|
+
log("exec-skills: settings parse failed (treated as no overrides)", {
|
|
131
|
+
path: settingsPath,
|
|
132
|
+
error: error instanceof Error ? error.message : String(error),
|
|
133
|
+
});
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* minimatch 子集 glob → RegExp:`**`(跨目录,段间匹配零段)/ `*`(段内任意)/ `?`
|
|
140
|
+
* (单字符,不跨 /)/ `[...]` 字符类;不支持 brace 展开等高级语法。已登记偏差:
|
|
141
|
+
* 极端 disable pattern(brace 等)可能与 pi 求值不一致——常用形态(裸名 / `x-*` /
|
|
142
|
+
* `dir/**`)语义一致。
|
|
143
|
+
*/
|
|
144
|
+
function globToRegExp(pattern: string): RegExp {
|
|
145
|
+
let source = "^";
|
|
146
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
147
|
+
const ch = pattern[i];
|
|
148
|
+
if (ch === "*") {
|
|
149
|
+
if (pattern[i + 1] === "*") {
|
|
150
|
+
while (pattern[i + 1] === "*") i++;
|
|
151
|
+
// 段间 `**/` 允许零段(minimatch globstar 语义),其余 `**` 跨目录任意
|
|
152
|
+
if (source.endsWith("/") && pattern[i + 1] === "/") {
|
|
153
|
+
source = `${source.slice(0, -1)}(?:.*/)?`;
|
|
154
|
+
i++;
|
|
155
|
+
} else {
|
|
156
|
+
source += ".*";
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
source += "[^/]*";
|
|
160
|
+
}
|
|
161
|
+
} else if (ch === "?") {
|
|
162
|
+
source += "[^/]";
|
|
163
|
+
} else if (ch === "[") {
|
|
164
|
+
const end = pattern.indexOf("]", i + 1);
|
|
165
|
+
if (end === -1) {
|
|
166
|
+
source += "\\[";
|
|
167
|
+
} else {
|
|
168
|
+
let cls = pattern.slice(i + 1, end);
|
|
169
|
+
if (cls.startsWith("!")) cls = `^${cls.slice(1)}`;
|
|
170
|
+
source += `[${cls}]`;
|
|
171
|
+
i = end;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
source += ch.replace(/[\\^$.+(){}|]/g, "\\$&");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return new RegExp(`${source}$`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** pi normalizeExactPattern 同式:剥前导 `./`(+/- 精确匹配形态归一,只剥一次) */
|
|
181
|
+
function normalizeExactPattern(pattern: string): string {
|
|
182
|
+
return toPosixPath(pattern.replace(/^\.\/|^\.\\/, ""));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* pi matchesAnyPattern 同构(`!` 排除侧的 glob 匹配):rel / 文件名 / 绝对路径,
|
|
187
|
+
* SKILL.md 再加父目录的 rel / 名 / 绝对路径三形态——`!dev-flow` 即靠父目录名命中。
|
|
188
|
+
*/
|
|
189
|
+
function matchesAnyGlobPattern(filePath: string, patterns: string[], baseDir: string): boolean {
|
|
190
|
+
if (patterns.length === 0) return false;
|
|
191
|
+
const regexes = patterns.map(globToRegExp);
|
|
192
|
+
const candidates = globMatchCandidates(filePath, baseDir);
|
|
193
|
+
return candidates.some((candidate) => regexes.some((re) => re.test(candidate)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 匹配候选形态(glob 侧 6 形态 / 精确侧 4 形态的公共部分按需取用) */
|
|
197
|
+
function globMatchCandidates(filePath: string, baseDir: string): string[] {
|
|
198
|
+
const rel = toPosixPath(relative(baseDir, filePath));
|
|
199
|
+
const name = basename(filePath);
|
|
200
|
+
const filePathPosix = toPosixPath(filePath);
|
|
201
|
+
const isSkillFile = name === "SKILL.md";
|
|
202
|
+
if (!isSkillFile) return [rel, name, filePathPosix];
|
|
203
|
+
const parentDir = dirname(filePath);
|
|
204
|
+
return [
|
|
205
|
+
rel,
|
|
206
|
+
name,
|
|
207
|
+
filePathPosix,
|
|
208
|
+
toPosixPath(relative(baseDir, parentDir)),
|
|
209
|
+
basename(parentDir),
|
|
210
|
+
toPosixPath(parentDir),
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** pi matchesAnyExactPattern 同构(`+`/`-` 精确侧):rel / 绝对路径 + SKILL.md 父目录两形态,无 glob */
|
|
215
|
+
function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
|
|
216
|
+
if (patterns.length === 0) return false;
|
|
217
|
+
const rel = toPosixPath(relative(baseDir, filePath));
|
|
218
|
+
const filePathPosix = toPosixPath(filePath);
|
|
219
|
+
const isSkillFile = basename(filePath) === "SKILL.md";
|
|
220
|
+
const parentDir = isSkillFile ? dirname(filePath) : undefined;
|
|
221
|
+
const parentRel = parentDir !== undefined ? toPosixPath(relative(baseDir, parentDir)) : undefined;
|
|
222
|
+
const parentDirPosix = parentDir !== undefined ? toPosixPath(parentDir) : undefined;
|
|
223
|
+
return patterns.some((pattern) => {
|
|
224
|
+
const normalized = normalizeExactPattern(pattern);
|
|
225
|
+
if (normalized === rel || normalized === filePathPosix) return true;
|
|
226
|
+
return (parentRel !== undefined && normalized === parentRel)
|
|
227
|
+
|| (parentDirPosix !== undefined && normalized === parentDirPosix);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** pi isEnabledByOverrides 同构:三级优先级 `-` > `+` > `!` > 默认启用(横切全部根) */
|
|
232
|
+
export function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean {
|
|
233
|
+
const overrides = patterns.filter((p) => p.startsWith("!") || p.startsWith("+") || p.startsWith("-"));
|
|
234
|
+
const excludes = overrides.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
|
|
235
|
+
const forceIncludes = overrides.filter((p) => p.startsWith("+")).map((p) => p.slice(1));
|
|
236
|
+
const forceExcludes = overrides.filter((p) => p.startsWith("-")).map((p) => p.slice(1));
|
|
237
|
+
let enabled = true;
|
|
238
|
+
if (excludes.length > 0 && matchesAnyGlobPattern(filePath, excludes, baseDir)) {
|
|
239
|
+
enabled = false;
|
|
240
|
+
}
|
|
241
|
+
if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
|
|
242
|
+
enabled = true;
|
|
243
|
+
}
|
|
244
|
+
if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) {
|
|
245
|
+
enabled = false;
|
|
246
|
+
}
|
|
247
|
+
return enabled;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** plan-exec marker 的 frontmatter 键(skill 侧自愿 opt-in:SKILL.md 加一行 `plan-exec: true`) */
|
|
251
|
+
const PLAN_EXEC_MARKER_FIELD = "plan-exec";
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 二次读 frontmatter 判 plan-exec marker(pi 的 Skill 对象不保留原始 frontmatter;
|
|
255
|
+
* pi 加载器对未知字段忽略不拒,marker 字段约定可行)。读/解析失败 → false + warn
|
|
256
|
+
* (降级:跳过该项)。严格 `=== true`:字符串 "true" 等 YAML 形态不误判。
|
|
257
|
+
*/
|
|
258
|
+
export function hasPlanExecMarker(skillFilePath: string, log: LogFn = defaultLog): boolean {
|
|
259
|
+
try {
|
|
260
|
+
const { frontmatter } = parseFrontmatter(fs.readFileSync(skillFilePath, "utf-8"));
|
|
261
|
+
return frontmatter[PLAN_EXEC_MARKER_FIELD] === true;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
log("exec-skills: frontmatter read failed (skill skipped)", {
|
|
264
|
+
path: skillFilePath,
|
|
265
|
+
error: error instanceof Error ? error.message : String(error),
|
|
266
|
+
});
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 检测产物:一个可提议为执行方式的 skill(name 与 pi prompt 列表一致,⑥) */
|
|
272
|
+
export interface ExecSkill {
|
|
273
|
+
name: string;
|
|
274
|
+
description: string;
|
|
275
|
+
/** skill 入口文件路径(标准形态 = SKILL.md 路径,散 .md 形态 = 文件本身;steer 指引 read 该路径,两种形态统一无分支) */
|
|
276
|
+
skillEntryPath: string;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface DetectExecSkillsOptions {
|
|
280
|
+
/** 项目目录(`<CONFIG_DIR_NAME>/skills` 根 + 祖先链起点,目录名跟 pi CONFIG_DIR_NAME 导出)。生产调用传 ctx.cwd */
|
|
281
|
+
cwd: string;
|
|
282
|
+
/** 项目信任态(untrusted 跳过 `.pi/skills` 与祖先链两族,④)。生产调用传 ctx.isProjectTrusted() */
|
|
283
|
+
trusted: boolean;
|
|
284
|
+
/** pi agentDir(`<agentDir>/skills` 根 + 全局 settings 位置)。缺省 pi getAgentDir()(读 PI_CODING_AGENT_DIR);测试注入 */
|
|
285
|
+
agentDir?: string;
|
|
286
|
+
/** 用户 home(`~/.agents/skills` 根)。缺省 `process.env.HOME || os.homedir()`(pi getHomeDir 同式);测试注入 */
|
|
287
|
+
homeDir?: string;
|
|
288
|
+
/** 降级留痕(warn 级),缺省 pi-extension-logger */
|
|
289
|
+
log?: LogFn;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 检测的单根描述:目录 + overrides 求值 baseDir(pi 按根挂各自 baseDir)+ 该根适用的 overrides */
|
|
293
|
+
interface SkillRoot {
|
|
294
|
+
dir: string;
|
|
295
|
+
baseDir: string;
|
|
296
|
+
overrides: string[];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 检测入口:枚举 pi 本体加载集的四根,返回带 plan-exec marker 的 skill 清单
|
|
301
|
+
* (root 序去重后)。现扫、无缓存(低频路径 + 技能热装可见性)。永不 throw(降级规格)。
|
|
302
|
+
*/
|
|
303
|
+
export function detectExecSkills(options: DetectExecSkillsOptions): ExecSkill[] {
|
|
304
|
+
const log: LogFn = options.log ?? defaultLog;
|
|
305
|
+
try {
|
|
306
|
+
return detectExecSkillsInternal(options, log);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
log("exec-skills: detection failed (degraded to empty option set)", {
|
|
309
|
+
error: error instanceof Error ? error.message : String(error),
|
|
310
|
+
});
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* trusted 门内两族根(④):`.pi/skills` 根 + 祖先链;overrides 挂 project settings。
|
|
317
|
+
* 祖先链每根 baseDir = 各自的 `.agents` 目录;与 `~/.agents/skills`
|
|
318
|
+
* 同路径的项滤掉(pi :1979 同款——cwd 在 HOME 下时防与第四根重复)。
|
|
319
|
+
*/
|
|
320
|
+
function collectTrustedProjectRoots(cwd: string, homeDir: string, projectOverrides: string[]): SkillRoot[] {
|
|
321
|
+
const roots: SkillRoot[] = [];
|
|
322
|
+
roots.push({ dir: join(cwd, CONFIG_DIR_NAME, "skills"), baseDir: join(cwd, CONFIG_DIR_NAME), overrides: projectOverrides });
|
|
323
|
+
const userAgentsSkillsDir = join(homeDir, ".agents", "skills");
|
|
324
|
+
for (const dir of collectAncestorAgentsSkillDirs(cwd)) {
|
|
325
|
+
if (resolve(dir) === resolve(userAgentsSkillsDir)) continue;
|
|
326
|
+
roots.push({ dir, baseDir: dirname(dir), overrides: projectOverrides });
|
|
327
|
+
}
|
|
328
|
+
return roots;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 四根 root 序组装(pi 本体加载集序,见 detectExecSkills 头注);overrides 按根挂载 */
|
|
332
|
+
function collectSkillRoots(
|
|
333
|
+
options: DetectExecSkillsOptions,
|
|
334
|
+
cwd: string,
|
|
335
|
+
agentDir: string,
|
|
336
|
+
homeDir: string,
|
|
337
|
+
log: LogFn,
|
|
338
|
+
): SkillRoot[] {
|
|
339
|
+
const userOverrides = readSkillsOverrides(join(agentDir, "settings.json"), log);
|
|
340
|
+
// project settings 仅 trusted 时参与(pi loadFromStorage 对 untrusted 的 project 恒 {})
|
|
341
|
+
const projectOverrides = options.trusted
|
|
342
|
+
? readSkillsOverrides(join(cwd, CONFIG_DIR_NAME, "settings.json"), log)
|
|
343
|
+
: [];
|
|
344
|
+
const roots: SkillRoot[] = options.trusted
|
|
345
|
+
? collectTrustedProjectRoots(cwd, homeDir, projectOverrides)
|
|
346
|
+
: [];
|
|
347
|
+
roots.push({ dir: join(agentDir, "skills"), baseDir: agentDir, overrides: userOverrides });
|
|
348
|
+
roots.push({ dir: join(homeDir, ".agents", "skills"), baseDir: join(homeDir, ".agents"), overrides: userOverrides });
|
|
349
|
+
return roots;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 单根扫描:overrides 过滤(②)→ first-writer-wins 去重(⑤,seen 集跨根共享)→
|
|
354
|
+
* marker 过滤。根目录缺失 / 整根读失败 → 空集(降级规格,不中断其余根)。
|
|
355
|
+
*/
|
|
356
|
+
function scanSkillRoot(
|
|
357
|
+
root: SkillRoot,
|
|
358
|
+
seenNames: Set<string>,
|
|
359
|
+
seenRealPaths: Set<string>,
|
|
360
|
+
log: LogFn,
|
|
361
|
+
): ExecSkill[] {
|
|
362
|
+
if (!fs.existsSync(root.dir)) return []; // 合法缺省(~/.agents/skills 不存在是常态主路径)
|
|
363
|
+
let skills: Skill[];
|
|
364
|
+
try {
|
|
365
|
+
skills = loadSkillsFromDir({ dir: root.dir, source: "detect" }).skills;
|
|
366
|
+
} catch (error) {
|
|
367
|
+
log("exec-skills: root scan failed (root skipped)", {
|
|
368
|
+
dir: root.dir,
|
|
369
|
+
error: error instanceof Error ? error.message : String(error),
|
|
370
|
+
});
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
const found: ExecSkill[] = [];
|
|
374
|
+
for (const skill of skills) {
|
|
375
|
+
// ② 用户显式 disable 的 skill 不提议(意图对齐)
|
|
376
|
+
if (!isEnabledByOverrides(skill.filePath, root.overrides, root.baseDir)) continue;
|
|
377
|
+
// ⑤ first-writer-wins(含非 marker skill 占名——pi 的 collision 语义以加载集为准,
|
|
378
|
+
// 后根同名不因 marker 缺席而让位)
|
|
379
|
+
const realPath = canonicalizePath(skill.filePath);
|
|
380
|
+
if (seenRealPaths.has(realPath) || seenNames.has(skill.name)) continue;
|
|
381
|
+
seenRealPaths.add(realPath);
|
|
382
|
+
seenNames.add(skill.name);
|
|
383
|
+
if (!hasPlanExecMarker(skill.filePath, log)) continue;
|
|
384
|
+
found.push({
|
|
385
|
+
name: skill.name,
|
|
386
|
+
description: skill.description,
|
|
387
|
+
// 入口文件路径两种形态统一:标准形态 filePath 即 SKILL.md 路径、散 .md 形态即
|
|
388
|
+
// 文件本身——接收端(steer 文案)直接 read 该值,不再拼 SKILL.md(散 .md 形态
|
|
389
|
+
// 拼接会得到不存在的 `<skills根>/SKILL.md` 悬空指引)
|
|
390
|
+
skillEntryPath: skill.filePath,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return found;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function detectExecSkillsInternal(options: DetectExecSkillsOptions, log: LogFn): ExecSkill[] {
|
|
397
|
+
const cwd = resolve(options.cwd);
|
|
398
|
+
const agentDir = resolve(options.agentDir ?? getAgentDir());
|
|
399
|
+
const homeDir = resolve(options.homeDir ?? defaultHomeDir());
|
|
400
|
+
|
|
401
|
+
const roots = collectSkillRoots(options, cwd, agentDir, homeDir, log);
|
|
402
|
+
|
|
403
|
+
const seenNames = new Set<string>();
|
|
404
|
+
const seenRealPaths = new Set<string>();
|
|
405
|
+
const found: ExecSkill[] = [];
|
|
406
|
+
for (const root of roots) {
|
|
407
|
+
for (const skill of scanSkillRoot(root, seenNames, seenRealPaths, log)) {
|
|
408
|
+
found.push(skill);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return found;
|
|
412
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zhushanwen/pi-exec-skills 公共出口(ADR-0074 落地包)。
|
|
3
|
+
*
|
|
4
|
+
* 两段消费语义闭环:exec-skills = 发现(目录扫描,技能热装即时可见);
|
|
5
|
+
* resolve-skills = 执行门禁(pi 注册表确认 pi 实际认得)。禁止各扩展自扫
|
|
6
|
+
* skill 目录或直用 pi 注册表快照形成第二实现。
|
|
7
|
+
*/
|
|
8
|
+
export { detectExecSkills, hasPlanExecMarker, isEnabledByOverrides } from "./exec-skills.js";
|
|
9
|
+
export type { DetectExecSkillsOptions, ExecSkill } from "./exec-skills.js";
|
|
10
|
+
export { normalizeSkillName, resolveSkills } from "./resolve-skills.js";
|
|
11
|
+
export type { SkillRef, SkillResolution } from "./resolve-skills.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 技能执行门禁共享能力(ADR-0074 落地包):steer/注入前的「pi 实际认得」确认,
|
|
5
|
+
* 走 pi 注册表(pi.getCommands)。与同包 exec-skills 模块构成两段消费语义闭环——
|
|
6
|
+
* 扫描(发现,自研直读磁盘,技能热装即时可见)≠ 本模块(执行门禁,pi 注册表
|
|
7
|
+
* 是执行权威);pi 不认得的技能只提示不注入。
|
|
8
|
+
*
|
|
9
|
+
* 门禁取注册表快照在此是有意选择:执行权在 pi 本体,快照滞后只造成「刚装的
|
|
10
|
+
* 技能本 turn 门禁拒绝」,重试即过——与「发现」侧的滞后不可接受(表单永远
|
|
11
|
+
* 看不到新技能)不同源。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** E1 技能解析结果:ok=false 时携带缺失项与可用清单(fail-fast 回复的材料) */
|
|
15
|
+
export type SkillResolution =
|
|
16
|
+
| { ok: true; resolved: SkillRef[] }
|
|
17
|
+
| { ok: false; available: string[]; missing: string[] };
|
|
18
|
+
|
|
19
|
+
/** 一个已确认可执行的技能引用:自然名 + 注册表回执的入口路径 */
|
|
20
|
+
export interface SkillRef { // oe-exempt:20261001:framework:exec-skills 共享包公开 API 条目形状(resolveSkills 返回值条目,包 index 导出供外部 pi 用户消费)——库契约接口先立、单实现常态
|
|
21
|
+
name: string;
|
|
22
|
+
skillPath: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 技能名归一:剥掉 pi 命令命名空间前导 `skill:` 前缀,得到自然技能名。
|
|
27
|
+
* pi.getCommands() 枚举 skill 类命令时 name 带 `skill:` 前缀(如 `skill:tech-design`),
|
|
28
|
+
* 这是 pi 的实现细节,不得泄漏到输入面——slash `--skills` 与消费方工具的 skills 参数
|
|
29
|
+
* 都用自然技能名。枚举侧与输入侧双向剥前缀后比对,兼容两种形态。
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeSkillName(name: string): string {
|
|
32
|
+
return name.startsWith("skill:") ? name.slice("skill:".length) : name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 执行门禁:pi.getCommands() 过滤 source === "skill" 枚举比对。比对前双向剥
|
|
37
|
+
* `skill:` 前缀归一:resolved/missing 用归一后的短名;available 维持枚举原形态
|
|
38
|
+
* (错误信息里可直接复制为 pi 命令)。
|
|
39
|
+
*
|
|
40
|
+
* 空请求 = 同族 fail-fast(「--skills 给了但没给名字」):返回 ok:false + 全量
|
|
41
|
+
* available + 空 missing——available 正是调用方报错文案的恢复动作输入(错误 →
|
|
42
|
+
* 可用清单 → 重试闭环),missing 空让报错器走「没有给技能名」分支。自 plan 包
|
|
43
|
+
* enter.ts 迁入(plan-mode-audit-remediation 批次 4②b):该分支是 slash 空
|
|
44
|
+
* `--skills` 报错复用单源的活契约,非死防御;既有调用方先判非空才调的,行为不受影响。
|
|
45
|
+
*/
|
|
46
|
+
export function resolveSkills(pi: ExtensionAPI, requested: string[]): SkillResolution {
|
|
47
|
+
const skillCommands = pi.getCommands().filter((c) => c.source === "skill");
|
|
48
|
+
const byShortName = new Map(skillCommands.map((c) => [normalizeSkillName(c.name), c.sourceInfo.path]));
|
|
49
|
+
const available = skillCommands.map((c) => c.name);
|
|
50
|
+
const resolved: SkillRef[] = [];
|
|
51
|
+
const missing: string[] = [];
|
|
52
|
+
for (const raw of requested) {
|
|
53
|
+
const name = normalizeSkillName(raw);
|
|
54
|
+
const skillPath = byShortName.get(name);
|
|
55
|
+
if (skillPath === undefined) {
|
|
56
|
+
missing.push(name);
|
|
57
|
+
} else {
|
|
58
|
+
resolved.push({ name, skillPath });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (requested.length === 0) {
|
|
62
|
+
return { ok: false, available, missing: [] };
|
|
63
|
+
}
|
|
64
|
+
return missing.length > 0 ? { ok: false, available, missing } : { ok: true, resolved };
|
|
65
|
+
}
|