@heihei0299/matt-skills 1.2.0 → 1.2.1
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/config/proprietary.json +1 -0
- package/package.json +3 -1
- package/scripts/sync-upstream.js +315 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
["tdd-implement", "grill-to-spec", "diagnose-fix", "commit-check", "instance-test"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heihei0299/matt-skills",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Agent skills + 项目配置模板:一条命令初始化 opencode / pi-agent 项目(含 mattpocock/skills 上游技能)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"bin/",
|
|
11
11
|
"template/",
|
|
12
12
|
".agents/skills/",
|
|
13
|
+
"scripts/sync-upstream.js",
|
|
14
|
+
"config/proprietary.json",
|
|
13
15
|
"README.md"
|
|
14
16
|
],
|
|
15
17
|
"scripts": {
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cp, readdir, readFile, rm, stat, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const LOCAL_SKILLS_DIR = path.join(ROOT, '.agents', 'skills');
|
|
11
|
+
const PROPRIETARY_PATH = path.join(ROOT, 'config', 'proprietary.json');
|
|
12
|
+
const UPSTREAM_URL = 'https://github.com/mattpocock/skills.git';
|
|
13
|
+
|
|
14
|
+
// 重命名映射:上游已重命名,本地旧名需迁移
|
|
15
|
+
const RENAMES = {
|
|
16
|
+
'writing-great-skills': 'writing-for-agents',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
async function loadProprietary() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(PROPRIETARY_PATH, 'utf8');
|
|
22
|
+
return new Set(JSON.parse(raw));
|
|
23
|
+
} catch {
|
|
24
|
+
return new Set(['tdd-implement', 'grill-to-spec', 'diagnose-fix', 'commit-check', 'instance-test']);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function hashFile(filePath) {
|
|
29
|
+
const buf = await readFile(filePath);
|
|
30
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function hashDir(dir) {
|
|
34
|
+
// 对 SKILL.md 做 hash,用于比对;若无 SKILL.md 则对目录内所有文件联合 hash
|
|
35
|
+
const skillMd = path.join(dir, 'SKILL.md');
|
|
36
|
+
try {
|
|
37
|
+
await stat(skillMd);
|
|
38
|
+
return await hashFile(skillMd);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function listSkills(dir) {
|
|
45
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (!e.isDirectory()) continue;
|
|
49
|
+
if (e.name.endsWith('.bak')) continue;
|
|
50
|
+
if (e.name === '.git') continue;
|
|
51
|
+
out.push(e.name);
|
|
52
|
+
}
|
|
53
|
+
return out.sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fetchUpstream({ tmpDir, upstreamUrl = UPSTREAM_URL, ref = 'HEAD' } = {}) {
|
|
57
|
+
const dest = tmpDir || path.join(os.tmpdir(), `matt-skills-upstream-${Date.now()}`);
|
|
58
|
+
await rm(dest, { recursive: true, force: true });
|
|
59
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
60
|
+
const clone = spawnSync('git', ['clone', '--depth', '1', upstreamUrl, dest], { encoding: 'utf8' });
|
|
61
|
+
if (clone.status !== 0) {
|
|
62
|
+
throw new Error(`git clone 失败: ${clone.stderr || clone.stdout}`);
|
|
63
|
+
}
|
|
64
|
+
if (ref && ref !== 'HEAD') {
|
|
65
|
+
const co = spawnSync('git', ['-C', dest, 'checkout', ref], { encoding: 'utf8' });
|
|
66
|
+
if (co.status !== 0) throw new Error(`git checkout ${ref} 失败: ${co.stderr}`);
|
|
67
|
+
}
|
|
68
|
+
const rev = spawnSync('git', ['-C', dest, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
69
|
+
const head = rev.stdout ? rev.stdout.trim() : 'unknown';
|
|
70
|
+
return { dest, head };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function collectUpstreamSkills(upstreamRoot) {
|
|
74
|
+
const map = new Map(); // name -> { dir, hash }
|
|
75
|
+
for (const bucket of ['engineering', 'productivity']) {
|
|
76
|
+
const bucketDir = path.join(upstreamRoot, 'skills', bucket);
|
|
77
|
+
let entries = [];
|
|
78
|
+
try {
|
|
79
|
+
entries = await readdir(bucketDir, { withFileTypes: true });
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
for (const e of entries) {
|
|
84
|
+
if (!e.isDirectory()) continue;
|
|
85
|
+
const skillDir = path.join(bucketDir, e.name);
|
|
86
|
+
const h = await hashDir(skillDir);
|
|
87
|
+
if (h) map.set(e.name, { dir: skillDir, hash: h, bucket });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function collectLocalSkills(proprietary) {
|
|
94
|
+
const map = new Map();
|
|
95
|
+
const names = await listSkills(LOCAL_SKILLS_DIR);
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
if (name === 'skill-creator') continue; // 本地符号链接,不纳入上游比对
|
|
98
|
+
if (proprietary.has(name)) continue;
|
|
99
|
+
const dir = path.join(LOCAL_SKILLS_DIR, name);
|
|
100
|
+
const h = await hashDir(dir);
|
|
101
|
+
map.set(name, { dir, hash: h });
|
|
102
|
+
}
|
|
103
|
+
return map;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function compare({ upstreamUrl, tmpDir, ref } = {}) {
|
|
107
|
+
const proprietary = await loadProprietary();
|
|
108
|
+
const fetched = await fetchUpstream({ tmpDir, upstreamUrl, ref });
|
|
109
|
+
const upstreamRoot = fetched.dest;
|
|
110
|
+
const upstreamMap = await collectUpstreamSkills(upstreamRoot);
|
|
111
|
+
const localMap = await collectLocalSkills(proprietary);
|
|
112
|
+
|
|
113
|
+
const added = [];
|
|
114
|
+
const updated = [];
|
|
115
|
+
const same = [];
|
|
116
|
+
const removed = [];
|
|
117
|
+
const renamed = [];
|
|
118
|
+
|
|
119
|
+
// 检测重命名:本地旧名存在且上游新名存在,且本地旧名不在上游
|
|
120
|
+
for (const [oldName, newName] of Object.entries(RENAMES)) {
|
|
121
|
+
if (localMap.has(oldName) && upstreamMap.has(newName) && !upstreamMap.has(oldName)) {
|
|
122
|
+
renamed.push({ from: oldName, to: newName });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const renamedFrom = new Set(renamed.map((r) => r.from));
|
|
126
|
+
const renamedTo = new Set(renamed.map((r) => r.to));
|
|
127
|
+
|
|
128
|
+
for (const [name, u] of upstreamMap.entries()) {
|
|
129
|
+
if (renamedTo.has(name)) continue; // 重命名的新名单独处理
|
|
130
|
+
const local = localMap.get(name);
|
|
131
|
+
if (!local) {
|
|
132
|
+
added.push(name);
|
|
133
|
+
} else if (local.hash !== u.hash) {
|
|
134
|
+
updated.push(name);
|
|
135
|
+
} else {
|
|
136
|
+
same.push(name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// 重命名的也算 updated(需迁移)
|
|
140
|
+
for (const r of renamed) {
|
|
141
|
+
const u = upstreamMap.get(r.to);
|
|
142
|
+
const local = localMap.get(r.from);
|
|
143
|
+
if (local && u && local.hash !== u.hash) updated.push(`${r.from}→${r.to}`);
|
|
144
|
+
else if (!local) added.push(r.to);
|
|
145
|
+
else same.push(`${r.from}→${r.to}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const name of localMap.keys()) {
|
|
149
|
+
if (renamedFrom.has(name)) continue;
|
|
150
|
+
if (!upstreamMap.has(name) && !renamedTo.has(name)) {
|
|
151
|
+
removed.push(name);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
head: fetched.head,
|
|
157
|
+
dest: fetched.dest,
|
|
158
|
+
upstreamMap,
|
|
159
|
+
localMap,
|
|
160
|
+
proprietary: [...proprietary],
|
|
161
|
+
result: { added: added.sort(), updated: updated.sort(), same: same.sort(), removed: removed.sort(), renamed },
|
|
162
|
+
counts: { upstream: upstreamMap.size, local: localMap.size },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function applySync({ upstreamUrl, tmpDir, ref, dryRun = false, force = false } = {}) {
|
|
167
|
+
const proprietary = await loadProprietary();
|
|
168
|
+
const cmp = await compare({ upstreamUrl, tmpDir, ref });
|
|
169
|
+
const { dest, head, upstreamMap, result } = cmp;
|
|
170
|
+
const actions = [];
|
|
171
|
+
|
|
172
|
+
if (dryRun) {
|
|
173
|
+
return { ...cmp, actions, dryRun: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 处理重命名:删除旧目录,复制新目录
|
|
177
|
+
for (const r of result.renamed) {
|
|
178
|
+
const src = upstreamMap.get(r.to).dir;
|
|
179
|
+
const dst = path.join(LOCAL_SKILLS_DIR, r.to);
|
|
180
|
+
const oldDst = path.join(LOCAL_SKILLS_DIR, r.from);
|
|
181
|
+
await rm(oldDst, { recursive: true, force: true });
|
|
182
|
+
await rm(dst, { recursive: true, force: true });
|
|
183
|
+
await cp(src, dst, { recursive: true, force: true });
|
|
184
|
+
actions.push(`rename ${r.from} → ${r.to}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 新增
|
|
188
|
+
for (const name of result.added) {
|
|
189
|
+
// 跳过已通过重命名处理的新名
|
|
190
|
+
if (result.renamed.some((r) => r.to === name)) continue;
|
|
191
|
+
const src = upstreamMap.get(name)?.dir;
|
|
192
|
+
if (!src) continue;
|
|
193
|
+
const dst = path.join(LOCAL_SKILLS_DIR, name);
|
|
194
|
+
await cp(src, dst, { recursive: true, force: true });
|
|
195
|
+
actions.push(`add ${name}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 更新
|
|
199
|
+
for (const name of result.updated) {
|
|
200
|
+
if (name.includes('→')) continue; // 已处理
|
|
201
|
+
const src = upstreamMap.get(name)?.dir;
|
|
202
|
+
if (!src) continue;
|
|
203
|
+
const dst = path.join(LOCAL_SKILLS_DIR, name);
|
|
204
|
+
await rm(dst, { recursive: true, force: true });
|
|
205
|
+
await cp(src, dst, { recursive: true, force: true });
|
|
206
|
+
actions.push(`update ${name}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 删除(上游已删且非独有)
|
|
210
|
+
for (const name of result.removed) {
|
|
211
|
+
const dst = path.join(LOCAL_SKILLS_DIR, name);
|
|
212
|
+
// 仅当非独有且上游确实不存在时删除;默认执行,需 --force 才删?按自动覆盖策略直接删
|
|
213
|
+
await rm(dst, { recursive: true, force: true });
|
|
214
|
+
actions.push(`remove ${name}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 清理临时目录
|
|
218
|
+
await rm(dest, { recursive: true, force: true });
|
|
219
|
+
|
|
220
|
+
return { ...cmp, dest: null, actions, head };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatTable(cmp) {
|
|
224
|
+
const { result, counts, head } = cmp;
|
|
225
|
+
const lines = [];
|
|
226
|
+
lines.push(`上游 HEAD: ${head}`);
|
|
227
|
+
lines.push(`本地非独有: ${counts.local} 上游: ${counts.upstream}`);
|
|
228
|
+
lines.push('');
|
|
229
|
+
const totalDiff = result.added.length + result.updated.length + result.removed.length + result.renamed.length;
|
|
230
|
+
if (totalDiff === 0) {
|
|
231
|
+
lines.push('✅ 已是最新,无差异');
|
|
232
|
+
} else {
|
|
233
|
+
if (result.added.length) lines.push(`新增 (${result.added.length}): ${result.added.join(', ')}`);
|
|
234
|
+
if (result.updated.length) lines.push(`更新 (${result.updated.length}): ${result.updated.join(', ')}`);
|
|
235
|
+
if (result.renamed.length) lines.push(`重命名 (${result.renamed.length}): ${result.renamed.map((r) => `${r.from}→${r.to}`).join(', ')}`);
|
|
236
|
+
if (result.removed.length) lines.push(`删除 (${result.removed.length}): ${result.removed.join(', ')}`);
|
|
237
|
+
if (result.same.length) lines.push(`一致 (${result.same.length}): ${result.same.join(', ')}`);
|
|
238
|
+
}
|
|
239
|
+
return lines.join('\n');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function main() {
|
|
243
|
+
const args = process.argv.slice(2);
|
|
244
|
+
const opts = {
|
|
245
|
+
json: args.includes('--json'),
|
|
246
|
+
dryRun: args.includes('--dry-run'),
|
|
247
|
+
force: args.includes('--force'),
|
|
248
|
+
check: args.includes('--check'),
|
|
249
|
+
apply: args.includes('--apply') || args.includes('--update'),
|
|
250
|
+
verbose: args.includes('--verbose') || args.includes('-v'),
|
|
251
|
+
};
|
|
252
|
+
const upstreamIdx = args.indexOf('--upstream');
|
|
253
|
+
const upstreamUrl = upstreamIdx !== -1 ? args[upstreamIdx + 1] : undefined;
|
|
254
|
+
const refIdx = args.indexOf('--ref');
|
|
255
|
+
const ref = refIdx !== -1 ? args[refIdx + 1] : undefined;
|
|
256
|
+
const tmpIdx = args.indexOf('--tmp');
|
|
257
|
+
const tmpDir = tmpIdx !== -1 ? args[tmpIdx + 1] : undefined;
|
|
258
|
+
|
|
259
|
+
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
|
260
|
+
process.stdout.write(`sync-upstream — 对比/同步 mattpocock/skills 上游技能
|
|
261
|
+
|
|
262
|
+
Usage:
|
|
263
|
+
node scripts/sync-upstream.js --check [--json] [--upstream <url>] [--ref <ref>]
|
|
264
|
+
node scripts/sync-upstream.js --apply [--dry-run] [--force] [--upstream <url>] [--ref <ref>]
|
|
265
|
+
|
|
266
|
+
Options:
|
|
267
|
+
--check 只对比,不改动文件(默认)
|
|
268
|
+
--apply 应用同步(覆盖 .agents/skills 非独有技能)
|
|
269
|
+
--dry-run 演练模式,不写文件
|
|
270
|
+
--json 以 JSON 输出结果
|
|
271
|
+
--upstream 上游仓库 URL(默认 https://github.com/mattpocock/skills.git)
|
|
272
|
+
--ref 上游 ref(默认 HEAD)
|
|
273
|
+
--tmp 指定临时目录(默认 os.tmpdir() 下随机)
|
|
274
|
+
--force 强制覆盖(apply 时默认即覆盖,此标志保留兼容)
|
|
275
|
+
--verbose 详细输出
|
|
276
|
+
`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (opts.apply) {
|
|
281
|
+
const res = await applySync({ upstreamUrl, tmpDir, ref, dryRun: opts.dryRun, force: opts.force });
|
|
282
|
+
if (opts.json) {
|
|
283
|
+
process.stdout.write(JSON.stringify({ head: res.head, result: res.result, actions: res.actions, dryRun: opts.dryRun }, null, 2) + '\n');
|
|
284
|
+
} else {
|
|
285
|
+
process.stdout.write(formatTable(res) + '\n');
|
|
286
|
+
if (res.actions.length) {
|
|
287
|
+
process.stdout.write(`\n已执行 ${res.actions.length} 项:\n`);
|
|
288
|
+
for (const a of res.actions) process.stdout.write(` - ${a}\n`);
|
|
289
|
+
}
|
|
290
|
+
if (opts.dryRun) process.stdout.write('\n(dry-run,未写文件)\n');
|
|
291
|
+
}
|
|
292
|
+
const hasDiff = res.result.added.length + res.result.updated.length + res.result.removed.length + res.result.renamed.length > 0;
|
|
293
|
+
if (opts.check && hasDiff) process.exitCode = 1;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 默认 --check
|
|
298
|
+
const cmp = await compare({ upstreamUrl, tmpDir, ref });
|
|
299
|
+
if (opts.json) {
|
|
300
|
+
process.stdout.write(JSON.stringify({ head: cmp.head, counts: cmp.counts, result: cmp.result }, null, 2) + '\n');
|
|
301
|
+
} else {
|
|
302
|
+
process.stdout.write(formatTable(cmp) + '\n');
|
|
303
|
+
}
|
|
304
|
+
const hasDiff = cmp.result.added.length + cmp.result.updated.length + cmp.result.removed.length + cmp.result.renamed.length > 0;
|
|
305
|
+
// 清理临时目录
|
|
306
|
+
await rm(cmp.dest, { recursive: true, force: true });
|
|
307
|
+
if (hasDiff) process.exitCode = 1;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1].endsWith('sync-upstream.js')) {
|
|
311
|
+
main().catch((e) => {
|
|
312
|
+
process.stderr.write(`error: ${e.message}\n`);
|
|
313
|
+
process.exitCode = 1;
|
|
314
|
+
});
|
|
315
|
+
}
|