@heihei0299/matt-skills 1.1.2 → 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/README.md +19 -0
- package/bin/cli.js +92 -1
- package/config/proprietary.json +1 -0
- package/package.json +3 -1
- package/scripts/sync-upstream.js +315 -0
package/README.md
CHANGED
|
@@ -131,6 +131,25 @@ node bin/cli.js install [选项] # 把技能复制到目标工
|
|
|
131
131
|
- `--global`:安装到全局目录(`~/.codex/skills`、`~/.pi/agent/skills`、`~/.config/opencode/skills`、`~/.claude/skills`);`--project` 回到项目级
|
|
132
132
|
- `--all`:安装全部技能(默认交互勾选);`--force`:覆盖已存在的技能
|
|
133
133
|
|
|
134
|
+
|
|
135
|
+
### 上游同步(自动更新)
|
|
136
|
+
|
|
137
|
+
本仓库的 `.agents/skills/` 中 **非独有技能** 来自 `mattpocock/skills` 上游。已实现双通道自动同步:
|
|
138
|
+
|
|
139
|
+
- **本地 CLI**:`matt-skills check`(只读比对)与 `matt-skills update`(一键覆盖本地 `.agents/skills/`,自动处理 `writing-great-skills → writing-for-agents` 重命名与增删)
|
|
140
|
+
- **GitHub Actions**:`.github/workflows/sync-upstream.yml` 每周一 02:00 UTC 自动 `check`,有差异则 `apply` 并提 PR(`upstream-sync/<short-sha>`),支持 `workflow_dispatch` 手动触发(`ref`/`dry_run` 参数)
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
npx @heihei0299/matt-skills check # 只读检查,对比本地 vs 上游 HEAD(有差异 exit 1)
|
|
144
|
+
npx @heihei0299/matt-skills check --json # JSON 输出:{ head, counts, result: { added, updated, renamed, removed, same } }
|
|
145
|
+
npx @heihei0299/matt-skills update --dry-run # 演练,不写文件
|
|
146
|
+
npx @heihei0299/matt-skills update # 覆盖 .agents/skills 非独有技能
|
|
147
|
+
node scripts/sync-upstream.js --check # 等价底层脚本(CLI check/update 的实现)
|
|
148
|
+
node scripts/sync-upstream.js --apply --dry-run
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
实现细节:`scripts/sync-upstream.js` 为单一事实源(CLI 与 Actions 共用),以 `config/proprietary.json` 为独有白名单,上游通过 `git clone --depth 1 https://github.com/mattpocock/skills.git` 获取,比对 `SKILL.md` 的 sha256,自动处理新增/更新/重命名/删除;Actions 提 PR 后需人工合入,合入后按“发布”节打 `v*` 标签即发布(自动 patch 发版可在后续扩展为 PR 合入后自动 bump)。
|
|
152
|
+
上游重命名映射:`RENAMES = { "writing-great-skills": "writing-for-agents" }`,Actions/CLI 均会删除旧目录并复制新目录。
|
|
134
153
|
## 发布
|
|
135
154
|
|
|
136
155
|
推送 `v*` 标签自动发布到 npm(GitHub Actions,见 `.github/workflows/publish.yml`):
|
package/bin/cli.js
CHANGED
|
@@ -20,6 +20,10 @@ Usage:
|
|
|
20
20
|
matt-skills sync [options] Sync existing project to latest template + skills
|
|
21
21
|
matt-skills list [--json] List available skills and their descriptions
|
|
22
22
|
matt-skills install [options] Install skills (interactive by default)
|
|
23
|
+
matt-skills check [--json] [--upstream <url>] [--ref <ref>]
|
|
24
|
+
Check if upstream skills are up to date (read-only)
|
|
25
|
+
matt-skills update [--dry-run] [--force] [--upstream <url>] [--ref <ref>]
|
|
26
|
+
Update .agents/skills from upstream (auto handles renames)
|
|
23
27
|
matt-skills --help Show this help
|
|
24
28
|
|
|
25
29
|
Init options:
|
|
@@ -28,6 +32,15 @@ Init options:
|
|
|
28
32
|
Sync options:
|
|
29
33
|
--dest <path> Target directory (default: current directory)
|
|
30
34
|
--force Overwrite existing files
|
|
35
|
+
Check options:
|
|
36
|
+
--json Output as JSON
|
|
37
|
+
--upstream <url> Upstream repo URL (default: https://github.com/mattpocock/skills.git)
|
|
38
|
+
--ref <ref> Upstream ref (default: HEAD)
|
|
39
|
+
Update options:
|
|
40
|
+
--dry-run Show what would change without writing files
|
|
41
|
+
--force Force overwrite (default is to overwrite; kept for compatibility)
|
|
42
|
+
--upstream <url> Upstream repo URL
|
|
43
|
+
--ref <ref> Upstream ref
|
|
31
44
|
|
|
32
45
|
Install options:
|
|
33
46
|
--tools <a,b> Install for the given tools (codex, pi, opencode, claude); skips tool selection
|
|
@@ -291,6 +304,77 @@ function parseInstallArgs(args) {
|
|
|
291
304
|
return { dest, all, force, global, tools };
|
|
292
305
|
}
|
|
293
306
|
|
|
307
|
+
async function checkCommand(args) {
|
|
308
|
+
const { compare } = await import('../scripts/sync-upstream.js');
|
|
309
|
+
const json = args.includes('--json');
|
|
310
|
+
const upstreamIdx = args.indexOf('--upstream');
|
|
311
|
+
const upstreamUrl = upstreamIdx !== -1 ? args[upstreamIdx + 1] : undefined;
|
|
312
|
+
const refIdx = args.indexOf('--ref');
|
|
313
|
+
const ref = refIdx !== -1 ? args[refIdx + 1] : undefined;
|
|
314
|
+
const cmp = await compare({ upstreamUrl, ref });
|
|
315
|
+
if (json) {
|
|
316
|
+
process.stdout.write(JSON.stringify({ head: cmp.head, counts: cmp.counts, result: cmp.result }, null, 2) + '\n');
|
|
317
|
+
} else {
|
|
318
|
+
const lines = [];
|
|
319
|
+
lines.push(`上游 HEAD: ${cmp.head}`);
|
|
320
|
+
lines.push(`本地非独有: ${cmp.counts.local} 上游: ${cmp.counts.upstream}`);
|
|
321
|
+
lines.push('');
|
|
322
|
+
const totalDiff = cmp.result.added.length + cmp.result.updated.length + cmp.result.removed.length + cmp.result.renamed.length;
|
|
323
|
+
if (totalDiff === 0) {
|
|
324
|
+
lines.push('✅ 已是最新,无差异');
|
|
325
|
+
} else {
|
|
326
|
+
if (cmp.result.added.length) lines.push(`新增 (${cmp.result.added.length}): ${cmp.result.added.join(', ')}`);
|
|
327
|
+
if (cmp.result.updated.length) lines.push(`更新 (${cmp.result.updated.length}): ${cmp.result.updated.join(', ')}`);
|
|
328
|
+
if (cmp.result.renamed.length) lines.push(`重命名 (${cmp.result.renamed.length}): ${cmp.result.renamed.map((r) => `${r.from}→${r.to}`).join(', ')}`);
|
|
329
|
+
if (cmp.result.removed.length) lines.push(`删除 (${cmp.result.removed.length}): ${cmp.result.removed.join(', ')}`);
|
|
330
|
+
if (cmp.result.same.length) lines.push(`一致 (${cmp.result.same.length}): ${cmp.result.same.join(', ')}`);
|
|
331
|
+
}
|
|
332
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
333
|
+
}
|
|
334
|
+
const { rm } = await import('node:fs/promises');
|
|
335
|
+
await rm(cmp.dest, { recursive: true, force: true });
|
|
336
|
+
const hasDiff = cmp.result.added.length + cmp.result.updated.length + cmp.result.removed.length + cmp.result.renamed.length > 0;
|
|
337
|
+
if (hasDiff) process.exitCode = 1;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function updateCommand(args) {
|
|
341
|
+
const { applySync } = await import('../scripts/sync-upstream.js');
|
|
342
|
+
const dryRun = args.includes('--dry-run');
|
|
343
|
+
const json = args.includes('--json');
|
|
344
|
+
const upstreamIdx = args.indexOf('--upstream');
|
|
345
|
+
const upstreamUrl = upstreamIdx !== -1 ? args[upstreamIdx + 1] : undefined;
|
|
346
|
+
const refIdx = args.indexOf('--ref');
|
|
347
|
+
const ref = refIdx !== -1 ? args[refIdx + 1] : undefined;
|
|
348
|
+
const res = await applySync({ upstreamUrl, ref, dryRun });
|
|
349
|
+
if (json) {
|
|
350
|
+
process.stdout.write(JSON.stringify({ head: res.head, result: res.result, actions: res.actions, dryRun }, null, 2) + '\n');
|
|
351
|
+
} else {
|
|
352
|
+
const lines = [];
|
|
353
|
+
lines.push(`上游 HEAD: ${res.head}`);
|
|
354
|
+
lines.push(`本地非独有: ${res.counts.local} 上游: ${res.counts.upstream}`);
|
|
355
|
+
lines.push('');
|
|
356
|
+
const totalDiff = res.result.added.length + res.result.updated.length + res.result.removed.length + res.result.renamed.length;
|
|
357
|
+
if (totalDiff === 0) {
|
|
358
|
+
lines.push('✅ 已是最新,无差异');
|
|
359
|
+
} else {
|
|
360
|
+
if (res.result.added.length) lines.push(`新增 (${res.result.added.length}): ${res.result.added.join(', ')}`);
|
|
361
|
+
if (res.result.updated.length) lines.push(`更新 (${res.result.updated.length}): ${res.result.updated.join(', ')}`);
|
|
362
|
+
if (res.result.renamed.length) lines.push(`重命名 (${res.result.renamed.length}): ${res.result.renamed.map((r) => `${r.from}→${r.to}`).join(', ')}`);
|
|
363
|
+
if (res.result.removed.length) lines.push(`删除 (${res.result.removed.length}): ${res.result.removed.join(', ')}`);
|
|
364
|
+
if (res.result.same.length) lines.push(`一致 (${res.result.same.length}): ${res.result.same.join(', ')}`);
|
|
365
|
+
}
|
|
366
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
367
|
+
if (res.actions.length) {
|
|
368
|
+
process.stdout.write(`\n已执行 ${res.actions.length} 项:\n`);
|
|
369
|
+
for (const a of res.actions) process.stdout.write(` - ${a}\n`);
|
|
370
|
+
}
|
|
371
|
+
if (dryRun) process.stdout.write('\n(dry-run,未写文件)\n');
|
|
372
|
+
}
|
|
373
|
+
if (!dryRun && res.actions.length) {
|
|
374
|
+
process.stdout.write('\n提示:请运行 npm test 验证,并按需执行 npm run build:template 更新模板镜像\n');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
294
378
|
async function main() {
|
|
295
379
|
const args = process.argv.slice(2);
|
|
296
380
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
@@ -321,10 +405,17 @@ async function main() {
|
|
|
321
405
|
await installCommand(parseInstallArgs(rest));
|
|
322
406
|
return;
|
|
323
407
|
}
|
|
408
|
+
if (command === 'check') {
|
|
409
|
+
await checkCommand(rest);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (command === 'update') {
|
|
413
|
+
await updateCommand(rest);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
324
416
|
process.stderr.write(HELP);
|
|
325
417
|
process.exitCode = 1;
|
|
326
418
|
}
|
|
327
|
-
|
|
328
419
|
main().catch((error) => {
|
|
329
420
|
process.stderr.write(`error: ${error.message}\n`);
|
|
330
421
|
process.exitCode = 1;
|
|
@@ -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.1
|
|
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
|
+
}
|