@netpilot/skills 0.3.2
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/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.codex-plugin/plugin.json +34 -0
- package/AGENTS.md +55 -0
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/SECURITY.md +7 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/agents/codex/architecture-designer.toml +11 -0
- package/agents/codex/backend-reviewer.toml +11 -0
- package/agents/codex/code-reader.toml +11 -0
- package/agents/codex/frontend-reviewer.toml +11 -0
- package/agents/codex/test-verifier.toml +11 -0
- package/bin/netpilot-skills.mjs +68 -0
- package/docs/agent-authoring.md +64 -0
- package/package.json +55 -0
- package/scripts/doctor.mjs +81 -0
- package/scripts/public-hygiene.mjs +232 -0
- package/scripts/sync.mjs +699 -0
- package/scripts/validate.mjs +461 -0
- package/skills/ask/SKILL.md +67 -0
- package/skills/ask/agents/openai.yaml +6 -0
- package/skills/code-review/SKILL.md +79 -0
- package/skills/code-review/agents/openai.yaml +6 -0
- package/skills/codebase-design/SKILL.md +78 -0
- package/skills/codebase-design/agents/openai.yaml +6 -0
- package/skills/diagnosing-bugs/SKILL.md +82 -0
- package/skills/diagnosing-bugs/agents/openai.yaml +6 -0
- package/skills/domain-modeling/SKILL.md +85 -0
- package/skills/domain-modeling/agents/openai.yaml +6 -0
- package/skills/grill/SKILL.md +54 -0
- package/skills/grill/agents/openai.yaml +6 -0
- package/skills/grill-with-docs/SKILL.md +75 -0
- package/skills/grill-with-docs/agents/openai.yaml +6 -0
- package/skills/grilling/SKILL.md +66 -0
- package/skills/grilling/agents/openai.yaml +6 -0
- package/skills/handoff/SKILL.md +72 -0
- package/skills/handoff/agents/openai.yaml +6 -0
- package/skills/implement/SKILL.md +68 -0
- package/skills/implement/agents/openai.yaml +6 -0
- package/skills/prototype/SKILL.md +71 -0
- package/skills/prototype/agents/openai.yaml +6 -0
- package/skills/research/SKILL.md +77 -0
- package/skills/research/agents/openai.yaml +6 -0
- package/skills/tdd/SKILL.md +71 -0
- package/skills/tdd/agents/openai.yaml +6 -0
- package/skills/teach/SKILL.md +68 -0
- package/skills/teach/agents/openai.yaml +6 -0
- package/skills/teach/references/glossary-format.md +21 -0
- package/skills/teach/references/learning-record-format.md +18 -0
- package/skills/teach/references/mission-format.md +28 -0
- package/skills/teach/references/resources-format.md +28 -0
- package/skills/to-spec/SKILL.md +76 -0
- package/skills/to-spec/agents/openai.yaml +6 -0
- package/skills/to-tickets/SKILL.md +69 -0
- package/skills/to-tickets/agents/openai.yaml +6 -0
- package/skills/wayfinder/SKILL.md +81 -0
- package/skills/wayfinder/agents/openai.yaml +6 -0
- package/skills/writing-great-skills/SKILL.md +83 -0
- package/skills/writing-great-skills/agents/openai.yaml +6 -0
package/scripts/sync.mjs
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFile, lstat, mkdir, open, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { validateCodexAgentDirectory } from "./validate.mjs";
|
|
8
|
+
|
|
9
|
+
const HOST_DESTINATIONS = {
|
|
10
|
+
codex: [".agents", "skills"],
|
|
11
|
+
claude: [".claude", "skills"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const CODEX_AGENT_DESTINATION = [".codex", "agents"];
|
|
15
|
+
|
|
16
|
+
const ALL_HOSTS = Object.freeze(Object.keys(HOST_DESTINATIONS));
|
|
17
|
+
const ALL_COMPONENTS = Object.freeze(["skills", "agents"]);
|
|
18
|
+
const SUPPORT_FILE_NAMES = Object.freeze(["LICENSE", "THIRD_PARTY_NOTICES.md"]);
|
|
19
|
+
|
|
20
|
+
export class SyncConflictError extends Error {
|
|
21
|
+
constructor(conflicts) {
|
|
22
|
+
super(`发现 ${conflicts.length} 个未受管理或已被用户修改的冲突文件`);
|
|
23
|
+
this.name = "SyncConflictError";
|
|
24
|
+
this.conflicts = conflicts;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseArgs(argv) {
|
|
29
|
+
let apply = false;
|
|
30
|
+
let homeDir = os.homedir();
|
|
31
|
+
let hosts = [...ALL_HOSTS];
|
|
32
|
+
let components = ["skills"];
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
35
|
+
const argument = argv[index];
|
|
36
|
+
if (argument === "--apply") {
|
|
37
|
+
apply = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (argument === "--host") {
|
|
41
|
+
const host = argv[index + 1];
|
|
42
|
+
if (!host) throw new Error("--host 需要 codex、claude 或 all");
|
|
43
|
+
index += 1;
|
|
44
|
+
if (host === "all") {
|
|
45
|
+
hosts = [...ALL_HOSTS];
|
|
46
|
+
} else if (host in HOST_DESTINATIONS) {
|
|
47
|
+
hosts = [host];
|
|
48
|
+
} else {
|
|
49
|
+
throw new Error(`不支持的宿主:${host}`);
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (argument === "--component") {
|
|
54
|
+
const component = argv[index + 1];
|
|
55
|
+
if (!component) throw new Error("--component 需要 skills、agents 或 all");
|
|
56
|
+
index += 1;
|
|
57
|
+
if (component === "all") {
|
|
58
|
+
components = [...ALL_COMPONENTS];
|
|
59
|
+
} else if (ALL_COMPONENTS.includes(component)) {
|
|
60
|
+
components = [component];
|
|
61
|
+
} else {
|
|
62
|
+
throw new Error(`不支持的组件:${component}`);
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (argument === "--home") {
|
|
67
|
+
const requestedHome = argv[index + 1];
|
|
68
|
+
if (!requestedHome || requestedHome.startsWith("-")) {
|
|
69
|
+
throw new Error("--home 需要一个目录路径");
|
|
70
|
+
}
|
|
71
|
+
index += 1;
|
|
72
|
+
homeDir = path.resolve(requestedHome);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`未知参数:${argument}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { apply, homeDir, hosts, components };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sha256(content) {
|
|
82
|
+
return createHash("sha256").update(content).digest("hex");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function hashFile(filePath) {
|
|
86
|
+
return sha256(await readFile(filePath));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readManifest(manifestPath) {
|
|
90
|
+
try {
|
|
91
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
92
|
+
if (
|
|
93
|
+
manifest.schemaVersion !== 1 ||
|
|
94
|
+
manifest.managedBy !== "netpilot-skills" ||
|
|
95
|
+
typeof manifest.files !== "object" ||
|
|
96
|
+
manifest.files === null ||
|
|
97
|
+
!Object.values(manifest.files).every(
|
|
98
|
+
(hash) => typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash),
|
|
99
|
+
)
|
|
100
|
+
) {
|
|
101
|
+
throw new Error("manifest schema 不受支持");
|
|
102
|
+
}
|
|
103
|
+
return manifest;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code === "ENOENT") return { schemaVersion: 1, managedBy: "netpilot-skills", files: {} };
|
|
106
|
+
throw new Error(`无法读取同步 manifest:${manifestPath}\n${error.message}`, { cause: error });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function listFiles(directory, prefix = "") {
|
|
111
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
112
|
+
const files = [];
|
|
113
|
+
|
|
114
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
115
|
+
const absolutePath = path.join(directory, entry.name);
|
|
116
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
117
|
+
if (entry.isSymbolicLink()) {
|
|
118
|
+
throw new Error(`skill 中不允许符号链接:${absolutePath}`);
|
|
119
|
+
}
|
|
120
|
+
if (entry.isDirectory()) {
|
|
121
|
+
files.push(...(await listFiles(absolutePath, relativePath)));
|
|
122
|
+
} else if (entry.isFile()) {
|
|
123
|
+
files.push({ absolutePath, relativePath });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function discoverSourceFiles(rootDir) {
|
|
131
|
+
const skillsRoot = path.join(rootDir, "skills");
|
|
132
|
+
const skillsRootStats = await lstat(skillsRoot);
|
|
133
|
+
if (!skillsRootStats.isDirectory() || skillsRootStats.isSymbolicLink()) {
|
|
134
|
+
throw new Error(`skills 根目录必须是普通目录:${skillsRoot}`);
|
|
135
|
+
}
|
|
136
|
+
const entries = await readdir(skillsRoot, { withFileTypes: true });
|
|
137
|
+
const sourceFiles = [];
|
|
138
|
+
|
|
139
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
140
|
+
if (entry.isSymbolicLink()) throw new Error(`skill 目录不允许符号链接:${path.join(skillsRoot, entry.name)}`);
|
|
141
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
142
|
+
const skillDir = path.join(skillsRoot, entry.name);
|
|
143
|
+
const files = await listFiles(skillDir);
|
|
144
|
+
if (!files.some((file) => file.relativePath === "SKILL.md")) {
|
|
145
|
+
throw new Error(`缺少 SKILL.md:${skillDir}`);
|
|
146
|
+
}
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
sourceFiles.push({ skillName: entry.name, ...file });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (sourceFiles.length === 0) throw new Error(`没有发现可同步的 skill:${skillsRoot}`);
|
|
153
|
+
return sourceFiles;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function discoverCodexAgentFiles(rootDir) {
|
|
157
|
+
const agentsRoot = path.join(rootDir, "agents", "codex");
|
|
158
|
+
const agentsRootStats = await lstat(agentsRoot);
|
|
159
|
+
if (!agentsRootStats.isDirectory() || agentsRootStats.isSymbolicLink()) {
|
|
160
|
+
throw new Error(`Codex agents 根目录必须是普通目录:${agentsRoot}`);
|
|
161
|
+
}
|
|
162
|
+
const entries = await readdir(agentsRoot, { withFileTypes: true });
|
|
163
|
+
const sourceFiles = [];
|
|
164
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
165
|
+
const absolutePath = path.join(agentsRoot, entry.name);
|
|
166
|
+
if (entry.isSymbolicLink()) throw new Error(`Codex agent 不允许符号链接:${absolutePath}`);
|
|
167
|
+
if (!entry.isFile() || !entry.name.endsWith(".toml")) {
|
|
168
|
+
throw new Error(`Codex agents 目录只允许 .toml 文件:${absolutePath}`);
|
|
169
|
+
}
|
|
170
|
+
sourceFiles.push({
|
|
171
|
+
component: "agents",
|
|
172
|
+
assetName: entry.name.slice(0, -".toml".length),
|
|
173
|
+
relativePath: entry.name,
|
|
174
|
+
absolutePath,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (sourceFiles.length === 0) throw new Error(`没有发现可同步的 Codex agent:${agentsRoot}`);
|
|
178
|
+
return sourceFiles;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function discoverSupportFiles(rootDir) {
|
|
182
|
+
const supportFiles = [];
|
|
183
|
+
for (const relativePath of SUPPORT_FILE_NAMES) {
|
|
184
|
+
const absolutePath = path.join(rootDir, relativePath);
|
|
185
|
+
const stats = await lstat(absolutePath);
|
|
186
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
187
|
+
throw new Error(`同步声明必须是普通文件:${absolutePath}`);
|
|
188
|
+
}
|
|
189
|
+
supportFiles.push({ skillName: "notices", relativePath, absolutePath });
|
|
190
|
+
}
|
|
191
|
+
return supportFiles;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function inspectTarget(targetPath) {
|
|
195
|
+
try {
|
|
196
|
+
const stats = await lstat(targetPath);
|
|
197
|
+
if (!stats.isFile()) return { exists: true, regularFile: false, hash: null };
|
|
198
|
+
return { exists: true, regularFile: true, hash: await hashFile(targetPath) };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error.code === "ENOENT") return { exists: false, regularFile: false, hash: null };
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function inspectAncestorPath(basePath, targetDirectory) {
|
|
206
|
+
const relativePath = path.relative(basePath, targetDirectory);
|
|
207
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
208
|
+
return { safe: false, path: targetDirectory, reason: "目标目录超出 home 边界" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let currentPath = basePath;
|
|
212
|
+
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
|
|
213
|
+
currentPath = path.join(currentPath, segment);
|
|
214
|
+
try {
|
|
215
|
+
const stats = await lstat(currentPath);
|
|
216
|
+
if (stats.isSymbolicLink()) {
|
|
217
|
+
return { safe: false, path: currentPath, reason: "目标父目录是 symlink 或 junction" };
|
|
218
|
+
}
|
|
219
|
+
if (!stats.isDirectory()) {
|
|
220
|
+
return { safe: false, path: currentPath, reason: "目标父路径不是目录" };
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error.code === "ENOENT") return { safe: true };
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { safe: true };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function manifestKey(host, component, assetName, relativePath) {
|
|
232
|
+
if (component === "skills") return `${host}/${assetName}/${relativePath}`;
|
|
233
|
+
if (component === "agents") return `${host}/agents/${assetName}/${relativePath}`;
|
|
234
|
+
return `${host}/${assetName}/${relativePath}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function planFileOperation({
|
|
238
|
+
host,
|
|
239
|
+
component,
|
|
240
|
+
assetName,
|
|
241
|
+
skillName = assetName,
|
|
242
|
+
relativePath,
|
|
243
|
+
sourcePath,
|
|
244
|
+
targetPath,
|
|
245
|
+
manifest,
|
|
246
|
+
homeDir,
|
|
247
|
+
}) {
|
|
248
|
+
const key = manifestKey(host, component, assetName, relativePath);
|
|
249
|
+
const sourceHash = await hashFile(sourcePath);
|
|
250
|
+
const ancestor = await inspectAncestorPath(homeDir, path.dirname(targetPath));
|
|
251
|
+
const target = ancestor.safe
|
|
252
|
+
? await inspectTarget(targetPath)
|
|
253
|
+
: { exists: false, regularFile: false, hash: null };
|
|
254
|
+
const previousHash = manifest.files[key] ?? null;
|
|
255
|
+
let action;
|
|
256
|
+
let reason = null;
|
|
257
|
+
|
|
258
|
+
if (!ancestor.safe) {
|
|
259
|
+
action = "conflict";
|
|
260
|
+
reason = `${ancestor.reason}:${ancestor.path}`;
|
|
261
|
+
} else if (!target.exists) {
|
|
262
|
+
action = "create";
|
|
263
|
+
} else if (!target.regularFile) {
|
|
264
|
+
action = "conflict";
|
|
265
|
+
reason = "目标路径不是普通文件";
|
|
266
|
+
} else if (target.hash === sourceHash && previousHash) {
|
|
267
|
+
action = "unchanged";
|
|
268
|
+
} else if (target.hash === sourceHash) {
|
|
269
|
+
action = "conflict";
|
|
270
|
+
reason = "同名文件不受本项目管理,即使内容相同也不会自动接管";
|
|
271
|
+
} else if (previousHash && target.hash === previousHash) {
|
|
272
|
+
action = "update";
|
|
273
|
+
} else {
|
|
274
|
+
action = "conflict";
|
|
275
|
+
reason = previousHash ? "文件在上次同步后被修改" : "同名文件不受本项目管理";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
host,
|
|
280
|
+
component,
|
|
281
|
+
assetName,
|
|
282
|
+
skillName,
|
|
283
|
+
relativePath,
|
|
284
|
+
sourcePath,
|
|
285
|
+
targetPath,
|
|
286
|
+
sourceHash,
|
|
287
|
+
targetHash: target.hash,
|
|
288
|
+
previousHash,
|
|
289
|
+
action,
|
|
290
|
+
reason,
|
|
291
|
+
manifestKey: key,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function collectChangedTargetConflicts(operations, homeDir) {
|
|
296
|
+
const conflicts = [];
|
|
297
|
+
for (const operation of operations) {
|
|
298
|
+
const ancestor = await inspectAncestorPath(homeDir, path.dirname(operation.targetPath));
|
|
299
|
+
if (!ancestor.safe) {
|
|
300
|
+
conflicts.push({
|
|
301
|
+
...operation,
|
|
302
|
+
action: "conflict",
|
|
303
|
+
reason: `${ancestor.reason}:${ancestor.path}`,
|
|
304
|
+
});
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const target = await inspectTarget(operation.targetPath);
|
|
309
|
+
if (operation.action === "create" && target.exists) {
|
|
310
|
+
conflicts.push({ ...operation, action: "conflict", reason: "目标在预检后被创建" });
|
|
311
|
+
} else if (
|
|
312
|
+
operation.action === "update" &&
|
|
313
|
+
(!target.regularFile || target.hash !== operation.targetHash)
|
|
314
|
+
) {
|
|
315
|
+
conflicts.push({ ...operation, action: "conflict", reason: "目标在预检后发生变化" });
|
|
316
|
+
} else if (
|
|
317
|
+
operation.action === "unchanged" &&
|
|
318
|
+
(!target.regularFile || target.hash !== operation.sourceHash)
|
|
319
|
+
) {
|
|
320
|
+
conflicts.push({ ...operation, action: "conflict", reason: "目标在预检后发生变化" });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return conflicts;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function applyOperationsTransactional({ operations, homeDir, manifestPath }) {
|
|
327
|
+
const stateDir = path.dirname(manifestPath);
|
|
328
|
+
await mkdir(stateDir, { recursive: true });
|
|
329
|
+
const stateSafety = await inspectAncestorPath(homeDir, stateDir);
|
|
330
|
+
if (!stateSafety.safe) {
|
|
331
|
+
throw new SyncConflictError([
|
|
332
|
+
{
|
|
333
|
+
host: "state",
|
|
334
|
+
skillName: "manifest",
|
|
335
|
+
relativePath: "manifest.json",
|
|
336
|
+
targetPath: manifestPath,
|
|
337
|
+
action: "conflict",
|
|
338
|
+
reason: `${stateSafety.reason}:${stateSafety.path}`,
|
|
339
|
+
},
|
|
340
|
+
]);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const stagingDir = path.join(stateDir, "staging");
|
|
344
|
+
const stagingSafety = await inspectAncestorPath(homeDir, stagingDir);
|
|
345
|
+
if (!stagingSafety.safe) {
|
|
346
|
+
throw new SyncConflictError([
|
|
347
|
+
{
|
|
348
|
+
host: "state",
|
|
349
|
+
skillName: "staging",
|
|
350
|
+
relativePath: "staging",
|
|
351
|
+
targetPath: stagingDir,
|
|
352
|
+
action: "conflict",
|
|
353
|
+
reason: `${stagingSafety.reason}:${stagingSafety.path}`,
|
|
354
|
+
},
|
|
355
|
+
]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const lockPath = path.join(stateDir, "sync.lock");
|
|
359
|
+
let lockHandle;
|
|
360
|
+
try {
|
|
361
|
+
lockHandle = await open(lockPath, "wx");
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error.code === "EEXIST") throw new Error(`已有同步进程持有锁:${lockPath}`);
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
await lockHandle.writeFile(`${process.pid}\n`, "utf8");
|
|
368
|
+
} catch (error) {
|
|
369
|
+
await lockHandle.close();
|
|
370
|
+
await rm(lockPath, { force: true });
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const stageRoot = path.join(stateDir, "staging", randomUUID());
|
|
375
|
+
const stagedPaths = new Map();
|
|
376
|
+
const applied = [];
|
|
377
|
+
const rollbackErrors = [];
|
|
378
|
+
let manifestBackupPath = null;
|
|
379
|
+
let manifestExisted = false;
|
|
380
|
+
let manifestChanged = false;
|
|
381
|
+
let preserveStage = false;
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
await mkdir(stageRoot, { recursive: true });
|
|
385
|
+
const stageRootSafety = await inspectAncestorPath(homeDir, stageRoot);
|
|
386
|
+
const realStateDir = await realpath(stateDir);
|
|
387
|
+
const realStageRoot = await realpath(stageRoot);
|
|
388
|
+
const realStageRelative = path.relative(realStateDir, realStageRoot);
|
|
389
|
+
if (
|
|
390
|
+
!stageRootSafety.safe ||
|
|
391
|
+
realStageRelative.startsWith("..") ||
|
|
392
|
+
path.isAbsolute(realStageRelative)
|
|
393
|
+
) {
|
|
394
|
+
preserveStage = true;
|
|
395
|
+
throw new SyncConflictError([
|
|
396
|
+
{
|
|
397
|
+
host: "state",
|
|
398
|
+
skillName: "staging",
|
|
399
|
+
relativePath: path.basename(stageRoot),
|
|
400
|
+
targetPath: stageRoot,
|
|
401
|
+
action: "conflict",
|
|
402
|
+
reason: "事务暂存目录解析到同步状态目录之外",
|
|
403
|
+
},
|
|
404
|
+
]);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (const operation of operations) {
|
|
408
|
+
if (operation.action !== "create" && operation.action !== "update") continue;
|
|
409
|
+
const stagedPath = path.join(stageRoot, "next", ...operation.manifestKey.split("/"));
|
|
410
|
+
await mkdir(path.dirname(stagedPath), { recursive: true });
|
|
411
|
+
await copyFile(operation.sourcePath, stagedPath);
|
|
412
|
+
if ((await hashFile(stagedPath)) !== operation.sourceHash) {
|
|
413
|
+
throw new Error(`暂存文件校验失败:${operation.sourcePath}`);
|
|
414
|
+
}
|
|
415
|
+
stagedPaths.set(operation.manifestKey, stagedPath);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const lockedManifest = await readManifest(manifestPath);
|
|
419
|
+
const manifestTarget = await inspectTarget(manifestPath);
|
|
420
|
+
if (manifestTarget.exists && !manifestTarget.regularFile) {
|
|
421
|
+
throw new Error(`同步 manifest 不是普通文件:${manifestPath}`);
|
|
422
|
+
}
|
|
423
|
+
if (manifestTarget.exists) {
|
|
424
|
+
manifestExisted = true;
|
|
425
|
+
manifestBackupPath = path.join(stageRoot, "backups", "manifest.json");
|
|
426
|
+
await mkdir(path.dirname(manifestBackupPath), { recursive: true });
|
|
427
|
+
await copyFile(manifestPath, manifestBackupPath);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const changedTargetConflicts = await collectChangedTargetConflicts(operations, homeDir);
|
|
431
|
+
if (changedTargetConflicts.length > 0) throw new SyncConflictError(changedTargetConflicts);
|
|
432
|
+
|
|
433
|
+
for (const operation of operations) {
|
|
434
|
+
if (operation.action !== "create" && operation.action !== "update") continue;
|
|
435
|
+
let backupPath = null;
|
|
436
|
+
if (operation.action === "update") {
|
|
437
|
+
backupPath = path.join(stageRoot, "backups", ...operation.manifestKey.split("/"));
|
|
438
|
+
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
439
|
+
await copyFile(operation.targetPath, backupPath);
|
|
440
|
+
if ((await hashFile(backupPath)) !== operation.targetHash) {
|
|
441
|
+
throw new Error(`目标备份校验失败:${operation.targetPath}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
applied.push({ operation, backupPath });
|
|
446
|
+
await mkdir(path.dirname(operation.targetPath), { recursive: true });
|
|
447
|
+
await copyFile(stagedPaths.get(operation.manifestKey), operation.targetPath);
|
|
448
|
+
if ((await hashFile(operation.targetPath)) !== operation.sourceHash) {
|
|
449
|
+
throw new Error(`同步后校验失败:${operation.targetPath}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const nextFiles = { ...lockedManifest.files };
|
|
454
|
+
for (const operation of operations) nextFiles[operation.manifestKey] = operation.sourceHash;
|
|
455
|
+
const nextManifest = {
|
|
456
|
+
schemaVersion: 1,
|
|
457
|
+
managedBy: "netpilot-skills",
|
|
458
|
+
updatedAt: new Date().toISOString(),
|
|
459
|
+
files: Object.fromEntries(Object.entries(nextFiles).sort(([left], [right]) => left.localeCompare(right))),
|
|
460
|
+
};
|
|
461
|
+
const stagedManifestPath = path.join(stageRoot, "next-manifest.json");
|
|
462
|
+
await mkdir(path.dirname(stagedManifestPath), { recursive: true });
|
|
463
|
+
await writeFile(stagedManifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, "utf8");
|
|
464
|
+
JSON.parse(await readFile(stagedManifestPath, "utf8"));
|
|
465
|
+
manifestChanged = true;
|
|
466
|
+
await copyFile(stagedManifestPath, manifestPath);
|
|
467
|
+
JSON.parse(await readFile(manifestPath, "utf8"));
|
|
468
|
+
} catch (error) {
|
|
469
|
+
for (const { operation, backupPath } of [...applied].reverse()) {
|
|
470
|
+
try {
|
|
471
|
+
if (backupPath) {
|
|
472
|
+
await copyFile(backupPath, operation.targetPath);
|
|
473
|
+
} else {
|
|
474
|
+
await rm(operation.targetPath, { force: true });
|
|
475
|
+
}
|
|
476
|
+
} catch (rollbackError) {
|
|
477
|
+
rollbackErrors.push(`${operation.targetPath}: ${rollbackError.message}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (manifestChanged) {
|
|
482
|
+
try {
|
|
483
|
+
if (manifestExisted) {
|
|
484
|
+
await copyFile(manifestBackupPath, manifestPath);
|
|
485
|
+
} else {
|
|
486
|
+
await rm(manifestPath, { force: true });
|
|
487
|
+
}
|
|
488
|
+
} catch (rollbackError) {
|
|
489
|
+
rollbackErrors.push(`${manifestPath}: ${rollbackError.message}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (rollbackErrors.length > 0) {
|
|
494
|
+
preserveStage = true;
|
|
495
|
+
throw new Error(
|
|
496
|
+
`同步失败且回滚不完整;暂存保留在 ${stageRoot}\n${rollbackErrors.join("\n")}`,
|
|
497
|
+
{ cause: error },
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
throw error;
|
|
501
|
+
} finally {
|
|
502
|
+
try {
|
|
503
|
+
await lockHandle.close();
|
|
504
|
+
} finally {
|
|
505
|
+
await rm(lockPath, { force: true });
|
|
506
|
+
}
|
|
507
|
+
if (!preserveStage) await rm(stageRoot, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export async function syncSkills({
|
|
512
|
+
rootDir,
|
|
513
|
+
homeDir,
|
|
514
|
+
hosts = [...ALL_HOSTS],
|
|
515
|
+
components = ["skills"],
|
|
516
|
+
apply = false,
|
|
517
|
+
}) {
|
|
518
|
+
const selectedHosts = [...new Set(hosts)];
|
|
519
|
+
const selectedComponents = [...new Set(components)];
|
|
520
|
+
for (const host of selectedHosts) {
|
|
521
|
+
if (!(host in HOST_DESTINATIONS)) throw new Error(`不支持的宿主:${host}`);
|
|
522
|
+
}
|
|
523
|
+
for (const component of selectedComponents) {
|
|
524
|
+
if (!ALL_COMPONENTS.includes(component)) throw new Error(`不支持的组件:${component}`);
|
|
525
|
+
}
|
|
526
|
+
if (
|
|
527
|
+
selectedComponents.includes("agents") &&
|
|
528
|
+
(selectedHosts.length !== 1 || selectedHosts[0] !== "codex")
|
|
529
|
+
) {
|
|
530
|
+
throw new Error("Codex agents 组件只支持显式选择 --host codex");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
534
|
+
const resolvedHome = path.resolve(homeDir);
|
|
535
|
+
const manifestPath = path.join(resolvedHome, ".netpilot-skills", "manifest.json");
|
|
536
|
+
const stateDir = path.dirname(manifestPath);
|
|
537
|
+
const stateSafety = await inspectAncestorPath(resolvedHome, stateDir);
|
|
538
|
+
if (!stateSafety.safe) {
|
|
539
|
+
throw new SyncConflictError([
|
|
540
|
+
{
|
|
541
|
+
host: "state",
|
|
542
|
+
skillName: "manifest",
|
|
543
|
+
relativePath: "manifest.json",
|
|
544
|
+
targetPath: manifestPath,
|
|
545
|
+
action: "conflict",
|
|
546
|
+
reason: `${stateSafety.reason}:${stateSafety.path}`,
|
|
547
|
+
},
|
|
548
|
+
]);
|
|
549
|
+
}
|
|
550
|
+
const manifestTarget = await inspectTarget(manifestPath);
|
|
551
|
+
if (manifestTarget.exists && !manifestTarget.regularFile) {
|
|
552
|
+
throw new SyncConflictError([
|
|
553
|
+
{
|
|
554
|
+
host: "state",
|
|
555
|
+
skillName: "manifest",
|
|
556
|
+
relativePath: "manifest.json",
|
|
557
|
+
targetPath: manifestPath,
|
|
558
|
+
action: "conflict",
|
|
559
|
+
reason: "同步 manifest 不是普通文件",
|
|
560
|
+
},
|
|
561
|
+
]);
|
|
562
|
+
}
|
|
563
|
+
const manifest = await readManifest(manifestPath);
|
|
564
|
+
const sourceFiles = selectedComponents.includes("skills")
|
|
565
|
+
? await discoverSourceFiles(resolvedRoot)
|
|
566
|
+
: [];
|
|
567
|
+
let codexAgentFiles = [];
|
|
568
|
+
if (selectedComponents.includes("agents")) {
|
|
569
|
+
const codexAgentRoot = path.join(resolvedRoot, "agents", "codex");
|
|
570
|
+
const agentValidation = await validateCodexAgentDirectory(codexAgentRoot);
|
|
571
|
+
if (agentValidation.errors.length > 0) {
|
|
572
|
+
throw new Error(`Codex agents 校验失败:\n- ${agentValidation.errors.join("\n- ")}`);
|
|
573
|
+
}
|
|
574
|
+
codexAgentFiles = await discoverCodexAgentFiles(resolvedRoot);
|
|
575
|
+
}
|
|
576
|
+
const supportFiles = await discoverSupportFiles(resolvedRoot);
|
|
577
|
+
const operations = [];
|
|
578
|
+
|
|
579
|
+
if (selectedComponents.includes("skills")) {
|
|
580
|
+
for (const host of selectedHosts) {
|
|
581
|
+
const hostRoot = path.join(resolvedHome, ...HOST_DESTINATIONS[host]);
|
|
582
|
+
for (const source of sourceFiles) {
|
|
583
|
+
const targetPath = path.join(hostRoot, source.skillName, ...source.relativePath.split("/"));
|
|
584
|
+
operations.push(await planFileOperation({
|
|
585
|
+
host,
|
|
586
|
+
component: "skills",
|
|
587
|
+
assetName: source.skillName,
|
|
588
|
+
skillName: source.skillName,
|
|
589
|
+
relativePath: source.relativePath,
|
|
590
|
+
sourcePath: source.absolutePath,
|
|
591
|
+
targetPath,
|
|
592
|
+
manifest,
|
|
593
|
+
homeDir: resolvedHome,
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (selectedComponents.includes("agents")) {
|
|
600
|
+
const agentRoot = path.join(resolvedHome, ...CODEX_AGENT_DESTINATION);
|
|
601
|
+
for (const source of codexAgentFiles) {
|
|
602
|
+
operations.push(await planFileOperation({
|
|
603
|
+
host: "codex",
|
|
604
|
+
component: "agents",
|
|
605
|
+
assetName: source.assetName,
|
|
606
|
+
skillName: source.assetName,
|
|
607
|
+
relativePath: source.relativePath,
|
|
608
|
+
sourcePath: source.absolutePath,
|
|
609
|
+
targetPath: path.join(agentRoot, source.relativePath),
|
|
610
|
+
manifest,
|
|
611
|
+
homeDir: resolvedHome,
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
for (const support of supportFiles) {
|
|
617
|
+
operations.push(await planFileOperation({
|
|
618
|
+
host: "state",
|
|
619
|
+
component: "support",
|
|
620
|
+
assetName: support.skillName,
|
|
621
|
+
skillName: support.skillName,
|
|
622
|
+
relativePath: support.relativePath,
|
|
623
|
+
sourcePath: support.absolutePath,
|
|
624
|
+
targetPath: path.join(stateDir, "notices", support.relativePath),
|
|
625
|
+
manifest,
|
|
626
|
+
homeDir: resolvedHome,
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const conflicts = operations.filter((operation) => operation.action === "conflict");
|
|
631
|
+
const result = {
|
|
632
|
+
mode: apply ? "apply" : "dry-run",
|
|
633
|
+
homeDir: resolvedHome,
|
|
634
|
+
manifestPath,
|
|
635
|
+
operations,
|
|
636
|
+
conflicts,
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
if (!apply) return result;
|
|
640
|
+
if (conflicts.length > 0) throw new SyncConflictError(conflicts);
|
|
641
|
+
await applyOperationsTransactional({ operations, homeDir: resolvedHome, manifestPath });
|
|
642
|
+
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function printHelp() {
|
|
647
|
+
console.log(`用法:
|
|
648
|
+
netpilot-skills install [--host codex|claude|all] [--component skills|agents|all] [--home PATH] [--apply]
|
|
649
|
+
npm run sync -- [--host codex|claude|all] [--component skills|agents|all] [--home PATH] [--apply]
|
|
650
|
+
|
|
651
|
+
默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
|
|
652
|
+
Codex 目标:~/.agents/skills
|
|
653
|
+
Claude Code 目标:~/.claude/skills
|
|
654
|
+
Codex agents 目标:~/.codex/agents(必须显式选择 --component agents 或 all)`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function printPlan(result) {
|
|
658
|
+
for (const operation of result.operations) {
|
|
659
|
+
const reason = operation.reason ? `(${operation.reason})` : "";
|
|
660
|
+
const component = operation.component === "skills" ? "skills" : operation.component;
|
|
661
|
+
console.log(`[${operation.action}] ${operation.host} ${component} ${operation.assetName}/${operation.relativePath}${reason}`);
|
|
662
|
+
}
|
|
663
|
+
const counts = Object.fromEntries(
|
|
664
|
+
["create", "update", "unchanged", "conflict"].map((action) => [
|
|
665
|
+
action,
|
|
666
|
+
result.operations.filter((operation) => operation.action === action).length,
|
|
667
|
+
]),
|
|
668
|
+
);
|
|
669
|
+
console.log(`汇总:新增 ${counts.create},更新 ${counts.update},不变 ${counts.unchanged},冲突 ${counts.conflict}`);
|
|
670
|
+
if (result.mode === "dry-run") console.log("当前为 dry-run;确认计划后追加 --apply。 ");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function main() {
|
|
674
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
675
|
+
printHelp();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const options = parseArgs(process.argv.slice(2));
|
|
679
|
+
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
|
680
|
+
try {
|
|
681
|
+
const result = await syncSkills({ rootDir, ...options });
|
|
682
|
+
printPlan(result);
|
|
683
|
+
if (result.conflicts.length > 0) process.exitCode = 2;
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (error instanceof SyncConflictError) {
|
|
686
|
+
for (const conflict of error.conflicts) {
|
|
687
|
+
console.error(`[conflict] ${conflict.targetPath}(${conflict.reason})`);
|
|
688
|
+
}
|
|
689
|
+
console.error(error.message);
|
|
690
|
+
process.exitCode = 2;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
698
|
+
await main();
|
|
699
|
+
}
|