wangchuan 1.0.2 → 2.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.
Files changed (52) hide show
  1. package/.wangchuan/config.example.json +44 -19
  2. package/README.md +145 -91
  3. package/dist/bin/wangchuan.js +9 -0
  4. package/dist/bin/wangchuan.js.map +1 -1
  5. package/dist/src/commands/diff.d.ts.map +1 -1
  6. package/dist/src/commands/diff.js +3 -1
  7. package/dist/src/commands/diff.js.map +1 -1
  8. package/dist/src/commands/dump.d.ts +9 -0
  9. package/dist/src/commands/dump.d.ts.map +1 -0
  10. package/dist/src/commands/dump.js +68 -0
  11. package/dist/src/commands/dump.js.map +1 -0
  12. package/dist/src/commands/list.d.ts +1 -1
  13. package/dist/src/commands/list.d.ts.map +1 -1
  14. package/dist/src/commands/list.js +26 -12
  15. package/dist/src/commands/list.js.map +1 -1
  16. package/dist/src/commands/pull.d.ts.map +1 -1
  17. package/dist/src/commands/pull.js +11 -1
  18. package/dist/src/commands/pull.js.map +1 -1
  19. package/dist/src/commands/push.d.ts.map +1 -1
  20. package/dist/src/commands/push.js +13 -5
  21. package/dist/src/commands/push.js.map +1 -1
  22. package/dist/src/commands/status.d.ts.map +1 -1
  23. package/dist/src/commands/status.js +5 -2
  24. package/dist/src/commands/status.js.map +1 -1
  25. package/dist/src/core/config.d.ts +8 -1
  26. package/dist/src/core/config.d.ts.map +1 -1
  27. package/dist/src/core/config.js +54 -15
  28. package/dist/src/core/config.js.map +1 -1
  29. package/dist/src/core/json-field.d.ts +17 -0
  30. package/dist/src/core/json-field.d.ts.map +1 -0
  31. package/dist/src/core/json-field.js +21 -0
  32. package/dist/src/core/json-field.js.map +1 -0
  33. package/dist/src/core/migrate.d.ts +22 -0
  34. package/dist/src/core/migrate.d.ts.map +1 -0
  35. package/dist/src/core/migrate.js +183 -0
  36. package/dist/src/core/migrate.js.map +1 -0
  37. package/dist/src/core/sync.d.ts +7 -0
  38. package/dist/src/core/sync.d.ts.map +1 -1
  39. package/dist/src/core/sync.js +423 -68
  40. package/dist/src/core/sync.js.map +1 -1
  41. package/dist/src/types.d.ts +62 -17
  42. package/dist/src/types.d.ts.map +1 -1
  43. package/dist/test/json-field.test.d.ts +5 -0
  44. package/dist/test/json-field.test.d.ts.map +1 -0
  45. package/dist/test/json-field.test.js +71 -0
  46. package/dist/test/json-field.test.js.map +1 -0
  47. package/dist/test/sync.test.d.ts +13 -0
  48. package/dist/test/sync.test.d.ts.map +1 -0
  49. package/dist/test/sync.test.js +477 -0
  50. package/dist/test/sync.test.js.map +1 -0
  51. package/package.json +2 -2
  52. package/skill/SKILL.md +52 -39
@@ -6,12 +6,17 @@
6
6
  * restoreFromRepo 本地仓库目录 → 工作区(拉取后还原)
7
7
  * diff 对比两侧,返回差异摘要
8
8
  *
9
+ * 支持三层同步:
10
+ * shared — 跨 agent 共享(skills、MCP 模板、共享记忆)
11
+ * agents/* — per-agent 跨环境同步
12
+ *
9
13
  * 所有方法支持可选的 agent 过滤参数,只操作指定智能体的文件。
10
14
  */
11
15
  import fs from 'fs';
12
16
  import path from 'path';
13
17
  import os from 'os';
14
18
  import { cryptoEngine } from './crypto.js';
19
+ import { jsonField } from './json-field.js';
15
20
  import { validator } from '../utils/validator.js';
16
21
  import { logger } from '../utils/logger.js';
17
22
  import { askConflict } from '../utils/prompt.js';
@@ -36,6 +41,138 @@ function walkDir(dirAbs) {
36
41
  fs.readdirSync(dirAbs).forEach(f => walk(f));
37
42
  return results;
38
43
  }
44
+ /** 按 repoRel 去重,保留第一个出现的条目 */
45
+ function deduplicateEntries(entries) {
46
+ const seen = new Set();
47
+ return entries.filter(e => {
48
+ if (seen.has(e.repoRel))
49
+ return false;
50
+ seen.add(e.repoRel);
51
+ return true;
52
+ });
53
+ }
54
+ const AGENT_NAMES = ['openclaw', 'claude', 'gemini'];
55
+ /**
56
+ * 为指定 agent profile 生成 syncFiles + syncDirs + jsonFields 条目
57
+ */
58
+ function buildAgentEntries(name, profile, repoDirBase) {
59
+ const entries = [];
60
+ const wsPath = expandHome(profile.workspacePath);
61
+ const repoPrefix = `agents/${name}`;
62
+ // syncFiles
63
+ for (const item of profile.syncFiles) {
64
+ const suffix = item.encrypt ? '.enc' : '';
65
+ entries.push({
66
+ srcAbs: path.join(wsPath, item.src),
67
+ repoRel: path.join(repoPrefix, item.src + suffix),
68
+ plainRel: path.join(repoPrefix, item.src),
69
+ encrypt: item.encrypt,
70
+ agentName: name,
71
+ });
72
+ }
73
+ // syncDirs
74
+ for (const dir of (profile.syncDirs ?? [])) {
75
+ const scanBase = repoDirBase
76
+ ? path.join(repoDirBase, repoPrefix, dir.src)
77
+ : path.join(wsPath, dir.src);
78
+ if (!fs.existsSync(scanBase))
79
+ continue;
80
+ for (const relFile of walkDir(scanBase)) {
81
+ const suffix = dir.encrypt ? '.enc' : '';
82
+ const plainFile = relFile.endsWith('.enc') ? relFile.slice(0, -4) : relFile;
83
+ entries.push({
84
+ srcAbs: path.join(wsPath, dir.src, plainFile),
85
+ repoRel: path.join(repoPrefix, dir.src, plainFile + suffix),
86
+ plainRel: path.join(repoPrefix, dir.src, plainFile),
87
+ encrypt: dir.encrypt,
88
+ agentName: name,
89
+ });
90
+ }
91
+ }
92
+ // jsonFields — 字段级 JSON 提取
93
+ for (const jf of (profile.jsonFields ?? [])) {
94
+ const suffix = jf.encrypt ? '.enc' : '';
95
+ entries.push({
96
+ srcAbs: path.join(wsPath, jf.src),
97
+ repoRel: path.join(repoPrefix, jf.repoName + suffix),
98
+ plainRel: path.join(repoPrefix, jf.repoName),
99
+ encrypt: jf.encrypt,
100
+ agentName: name,
101
+ jsonExtract: {
102
+ fields: jf.fields,
103
+ originalPath: path.join(wsPath, jf.src),
104
+ },
105
+ });
106
+ }
107
+ return entries;
108
+ }
109
+ /**
110
+ * 构建 shared tier 条目(skills、MCP 模板、共享文件)
111
+ */
112
+ function buildSharedEntries(cfg, repoDirBase) {
113
+ const entries = [];
114
+ const shared = cfg.shared;
115
+ if (!shared)
116
+ return entries;
117
+ const profiles = cfg.profiles.default;
118
+ // ── shared skills:多源汇聚 ────────────────────────────────
119
+ for (const source of shared.skills.sources) {
120
+ const p = profiles[source.agent];
121
+ if (!p.enabled)
122
+ continue;
123
+ const wsPath = expandHome(p.workspacePath);
124
+ const scanBase = repoDirBase
125
+ ? path.join(repoDirBase, 'shared', 'skills')
126
+ : path.join(wsPath, source.dir);
127
+ if (!fs.existsSync(scanBase))
128
+ continue;
129
+ for (const relFile of walkDir(scanBase)) {
130
+ // 跳过 .DS_Store 等系统文件
131
+ if (path.basename(relFile).startsWith('.'))
132
+ continue;
133
+ entries.push({
134
+ srcAbs: path.join(wsPath, source.dir, relFile),
135
+ repoRel: path.join('shared', 'skills', relFile),
136
+ plainRel: path.join('shared', 'skills', relFile),
137
+ encrypt: false,
138
+ agentName: 'shared',
139
+ });
140
+ }
141
+ }
142
+ // ── shared MCP:从各 agent 的 JSON 中提取 mcpServers ──────
143
+ for (const source of shared.mcp.sources) {
144
+ const p = profiles[source.agent];
145
+ if (!p.enabled)
146
+ continue;
147
+ const wsPath = expandHome(p.workspacePath);
148
+ const srcPath = path.join(wsPath, source.src);
149
+ const repoName = `mcp/${source.agent}-${source.field}.json`;
150
+ entries.push({
151
+ srcAbs: srcPath,
152
+ repoRel: path.join('shared', repoName + '.enc'),
153
+ plainRel: path.join('shared', repoName),
154
+ encrypt: true,
155
+ agentName: 'shared',
156
+ jsonExtract: {
157
+ fields: [source.field],
158
+ originalPath: srcPath,
159
+ },
160
+ });
161
+ }
162
+ // ── shared syncFiles ───────────────────────────────────────
163
+ for (const item of shared.syncFiles) {
164
+ const wsPath = expandHome(item.workspacePath);
165
+ const suffix = item.encrypt ? '.enc' : '';
166
+ entries.push({
167
+ srcAbs: path.join(wsPath, item.src),
168
+ repoRel: path.join('shared', item.src + suffix),
169
+ plainRel: path.join('shared', item.src),
170
+ encrypt: item.encrypt,
171
+ agentName: 'shared',
172
+ });
173
+ }
174
+ return entries;
175
+ }
39
176
  /**
40
177
  * 构建需要同步的文件条目列表(所有同步方向的单一事实来源)。
41
178
  *
@@ -44,83 +181,204 @@ function walkDir(dirAbs) {
44
181
  */
45
182
  export function buildFileEntries(cfg, repoDirBase, agent) {
46
183
  const entries = [];
47
- const profile = cfg.profiles.default;
48
- // ── OpenClaw ────────────────────────────────────────────────
49
- if (profile.openclaw.enabled && (!agent || agent === 'openclaw')) {
50
- const wsPath = expandHome(profile.openclaw.workspacePath);
51
- for (const item of profile.openclaw.syncFiles) {
52
- const suffix = item.encrypt ? '.enc' : '';
53
- entries.push({
54
- srcAbs: path.join(wsPath, item.src),
55
- repoRel: path.join('openclaw', item.src + suffix),
56
- plainRel: path.join('openclaw', item.src),
57
- encrypt: item.encrypt,
58
- agentName: 'openclaw',
59
- });
184
+ const profiles = cfg.profiles.default;
185
+ // per-agent 条目
186
+ for (const name of AGENT_NAMES) {
187
+ const p = profiles[name];
188
+ if (!p.enabled || (agent && agent !== name))
189
+ continue;
190
+ entries.push(...buildAgentEntries(name, p, repoDirBase));
191
+ }
192
+ // shared 条目(--agent 过滤时不包含 shared,因为 shared 不属于任何单一 agent)
193
+ if (!agent) {
194
+ entries.push(...buildSharedEntries(cfg, repoDirBase));
195
+ }
196
+ return deduplicateEntries(entries);
197
+ }
198
+ /**
199
+ * 将 shared 内容(skills、MCP 配置)分发到各 agent 的本地目录。
200
+ * push 前调用,确保各 agent 在推送前已获得最新共享资源。
201
+ */
202
+ function distributeShared(cfg) {
203
+ const shared = cfg.shared;
204
+ if (!shared)
205
+ return;
206
+ const profiles = cfg.profiles.default;
207
+ // ── 分发 skills:从每个 source 收集,只添加对方完全没有的新 skill ──
208
+ // 收集各 agent 当前拥有的 skill 集合
209
+ const agentSkills = new Map(); // agent → relPath → absPath
210
+ for (const source of shared.skills.sources) {
211
+ const p = profiles[source.agent];
212
+ if (!p.enabled)
213
+ continue;
214
+ const skillsDir = path.join(expandHome(p.workspacePath), source.dir);
215
+ const skills = new Map();
216
+ if (fs.existsSync(skillsDir)) {
217
+ for (const relFile of walkDir(skillsDir)) {
218
+ if (path.basename(relFile).startsWith('.'))
219
+ continue;
220
+ skills.set(relFile, path.join(skillsDir, relFile));
221
+ }
60
222
  }
61
- for (const dir of profile.openclaw.syncDirs) {
62
- const scanBase = repoDirBase
63
- ? path.join(repoDirBase, 'openclaw', dir.src)
64
- : path.join(wsPath, dir.src);
65
- if (!fs.existsSync(scanBase))
66
- continue;
67
- for (const relFile of walkDir(scanBase)) {
68
- const suffix = dir.encrypt ? '.enc' : '';
69
- const plainFile = relFile.endsWith('.enc') ? relFile.slice(0, -4) : relFile;
70
- entries.push({
71
- srcAbs: path.join(wsPath, dir.src, plainFile),
72
- repoRel: path.join('openclaw', dir.src, plainFile + suffix),
73
- plainRel: path.join('openclaw', dir.src, plainFile),
74
- encrypt: dir.encrypt,
75
- agentName: 'openclaw',
76
- });
223
+ agentSkills.set(source.agent, skills);
224
+ }
225
+ // 合并所有 agent 的 skill(去重,先出现者优先)
226
+ const allSkills = new Map();
227
+ for (const skills of agentSkills.values()) {
228
+ for (const [rel, abs] of skills) {
229
+ if (!allSkills.has(rel))
230
+ allSkills.set(rel, abs);
231
+ }
232
+ }
233
+ // 分发:只把某 agent 缺少的 skill 复制过去(skill 存在于全局集合中)
234
+ for (const source of shared.skills.sources) {
235
+ const p = profiles[source.agent];
236
+ if (!p.enabled)
237
+ continue;
238
+ const mySkills = agentSkills.get(source.agent);
239
+ const skillsDir = path.join(expandHome(p.workspacePath), source.dir);
240
+ for (const [relFile, srcAbs] of allSkills) {
241
+ if (mySkills.has(relFile))
242
+ continue; // 已有,不覆盖
243
+ const dest = path.join(skillsDir, relFile);
244
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
245
+ fs.copyFileSync(srcAbs, dest);
246
+ logger.debug(` 分发 skill: ${relFile} → ${source.agent}`);
247
+ }
248
+ }
249
+ // ── 分发 MCP 配置:从每个 source 提取,merge 到其他 agent ──
250
+ const mergedMcp = {};
251
+ for (const source of shared.mcp.sources) {
252
+ const p = profiles[source.agent];
253
+ if (!p.enabled)
254
+ continue;
255
+ const srcPath = path.join(expandHome(p.workspacePath), source.src);
256
+ if (!fs.existsSync(srcPath))
257
+ continue;
258
+ try {
259
+ const json = JSON.parse(fs.readFileSync(srcPath, 'utf-8'));
260
+ const mcpField = json[source.field];
261
+ if (mcpField && typeof mcpField === 'object') {
262
+ Object.assign(mergedMcp, mcpField);
77
263
  }
78
264
  }
265
+ catch { /* 忽略解析失败 */ }
79
266
  }
80
- // ── Claude ──────────────────────────────────────────────────
81
- if (profile.claude.enabled && (!agent || agent === 'claude')) {
82
- const wsPath = expandHome(profile.claude.workspacePath);
83
- for (const item of profile.claude.syncFiles) {
84
- const suffix = item.encrypt ? '.enc' : '';
85
- entries.push({
86
- srcAbs: path.join(wsPath, item.src),
87
- repoRel: path.join('claude', item.src + suffix),
88
- plainRel: path.join('claude', item.src),
89
- encrypt: item.encrypt,
90
- agentName: 'claude',
91
- });
267
+ // 写回到每个 source agent 的 MCP 配置(文件不存在时创建)
268
+ if (Object.keys(mergedMcp).length > 0) {
269
+ for (const source of shared.mcp.sources) {
270
+ const p = profiles[source.agent];
271
+ if (!p.enabled)
272
+ continue;
273
+ const srcPath = path.join(expandHome(p.workspacePath), source.src);
274
+ try {
275
+ let json = {};
276
+ if (fs.existsSync(srcPath)) {
277
+ json = JSON.parse(fs.readFileSync(srcPath, 'utf-8'));
278
+ }
279
+ const currentMcp = (json[source.field] ?? {});
280
+ // 只添加本地没有的 MCP server,不覆盖已有配置
281
+ let changed = false;
282
+ for (const [key, val] of Object.entries(mergedMcp)) {
283
+ if (!(key in currentMcp)) {
284
+ currentMcp[key] = val;
285
+ changed = true;
286
+ }
287
+ }
288
+ if (changed) {
289
+ json[source.field] = currentMcp;
290
+ fs.mkdirSync(path.dirname(srcPath), { recursive: true });
291
+ fs.writeFileSync(srcPath, JSON.stringify(json, null, 2), 'utf-8');
292
+ logger.debug(` 分发 MCP servers → ${source.agent}`);
293
+ }
294
+ }
295
+ catch { /* 忽略 */ }
92
296
  }
93
297
  }
94
- // ── Gemini ──────────────────────────────────────────────────
95
- if (profile.gemini.enabled && (!agent || agent === 'gemini')) {
96
- const wsPath = expandHome(profile.gemini.workspacePath);
97
- for (const item of profile.gemini.syncFiles) {
98
- const suffix = item.encrypt ? '.enc' : '';
99
- entries.push({
100
- srcAbs: path.join(wsPath, item.src),
101
- repoRel: path.join('gemini', item.src + suffix),
102
- plainRel: path.join('gemini', item.src),
103
- encrypt: item.encrypt,
104
- agentName: 'gemini',
105
- });
298
+ }
299
+ /**
300
+ * 清理 repo 中的过期文件 — repo 有但当前 entries 中不包含的条目删除。
301
+ * 仅清理 agents/ 和 shared/ 目录下的文件(不删 .git 等)。
302
+ */
303
+ function pruneRepoStaleFiles(repoPath, entries) {
304
+ const activeRepoRels = new Set(entries.map(e => e.repoRel));
305
+ const deleted = [];
306
+ for (const topDir of ['agents', 'shared']) {
307
+ const scanRoot = path.join(repoPath, topDir);
308
+ if (!fs.existsSync(scanRoot))
309
+ continue;
310
+ for (const relFile of walkDir(scanRoot)) {
311
+ if (path.basename(relFile).startsWith('.'))
312
+ continue;
313
+ const repoRel = path.join(topDir, relFile);
314
+ if (!activeRepoRels.has(repoRel)) {
315
+ const abs = path.join(repoPath, repoRel);
316
+ fs.unlinkSync(abs);
317
+ deleted.push(repoRel);
318
+ logger.debug(` repo 清理过期文件: ${repoRel}`);
319
+ // 清理空目录
320
+ let dir = path.dirname(abs);
321
+ while (dir !== scanRoot && dir.startsWith(scanRoot)) {
322
+ const remaining = fs.readdirSync(dir);
323
+ if (remaining.length === 0) {
324
+ fs.rmdirSync(dir);
325
+ dir = path.dirname(dir);
326
+ }
327
+ else {
328
+ break;
329
+ }
330
+ }
331
+ }
106
332
  }
107
333
  }
108
- return entries;
334
+ return deleted;
109
335
  }
110
336
  export const syncEngine = {
111
337
  expandHome,
112
338
  buildFileEntries,
339
+ /**
340
+ * 推送前:先分发 shared 内容到各 agent,再收集文件到 repo。
341
+ */
113
342
  async stageToRepo(cfg, agent) {
343
+ // 推送全部时,先分发 shared 资源到各 agent
344
+ if (!agent) {
345
+ distributeShared(cfg);
346
+ }
114
347
  const repoPath = expandHome(cfg.localRepoPath);
115
348
  const keyPath = expandHome(cfg.keyPath);
116
349
  const entries = buildFileEntries(cfg, undefined, agent);
117
- const result = { synced: [], skipped: [], encrypted: [] };
350
+ const result = { synced: [], skipped: [], encrypted: [], deleted: [] };
118
351
  for (const entry of entries) {
119
352
  if (!fs.existsSync(entry.srcAbs)) {
120
353
  logger.debug(`跳过(不存在): ${entry.srcAbs}`);
121
354
  result.skipped.push(entry.srcAbs);
122
355
  continue;
123
356
  }
357
+ const destAbs = path.join(repoPath, entry.repoRel);
358
+ fs.mkdirSync(path.dirname(destAbs), { recursive: true });
359
+ // ── JSON 字段级提取 ─────────────────────────────────────
360
+ if (entry.jsonExtract) {
361
+ try {
362
+ const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
363
+ const partial = jsonField.extractFields(fullJson, entry.jsonExtract.fields);
364
+ const content = JSON.stringify(partial, null, 2);
365
+ if (entry.encrypt) {
366
+ const encrypted = cryptoEngine.encryptString(content, keyPath);
367
+ fs.writeFileSync(destAbs, encrypted, 'utf-8');
368
+ result.encrypted.push(entry.repoRel);
369
+ }
370
+ else {
371
+ fs.writeFileSync(destAbs, content, 'utf-8');
372
+ }
373
+ result.synced.push(entry.repoRel);
374
+ }
375
+ catch (err) {
376
+ logger.warn(`跳过 JSON 字段提取(解析失败): ${entry.srcAbs} — ${err.message}`);
377
+ result.skipped.push(entry.repoRel);
378
+ }
379
+ continue;
380
+ }
381
+ // ── 整文件同步(原有逻辑) ────────────────────────────────
124
382
  if (!entry.encrypt) {
125
383
  const content = fs.readFileSync(entry.srcAbs, 'utf-8');
126
384
  if (validator.containsSensitiveData(content)) {
@@ -128,8 +386,6 @@ export const syncEngine = {
128
386
  logger.warn(` 建议在配置中将该文件标记为 encrypt:true`);
129
387
  }
130
388
  }
131
- const destAbs = path.join(repoPath, entry.repoRel);
132
- fs.mkdirSync(path.dirname(destAbs), { recursive: true });
133
389
  if (entry.encrypt) {
134
390
  cryptoEngine.encryptFile(entry.srcAbs, destAbs, keyPath);
135
391
  result.encrypted.push(entry.repoRel);
@@ -139,23 +395,106 @@ export const syncEngine = {
139
395
  }
140
396
  result.synced.push(entry.repoRel);
141
397
  }
398
+ // ── 清理 repo 中的过期文件(全量 push 时) ──────────────────
399
+ // 只用实际写入 repo 的条目做 prune,排除本地不存在的 skipped 条目
400
+ if (!agent) {
401
+ const syncedEntries = entries.filter(e => fs.existsSync(e.srcAbs));
402
+ const pruned = pruneRepoStaleFiles(repoPath, syncedEntries);
403
+ result.deleted.push(...pruned);
404
+ }
142
405
  return result;
143
406
  },
144
407
  async restoreFromRepo(cfg, agent) {
145
408
  const repoPath = expandHome(cfg.localRepoPath);
146
409
  const keyPath = expandHome(cfg.keyPath);
147
410
  const entries = buildFileEntries(cfg, repoPath, agent);
148
- const result = { synced: [], skipped: [], decrypted: [], conflicts: [] };
149
- // 批量决策:overwrite_all / skip_all
411
+ const result = { synced: [], skipped: [], decrypted: [], conflicts: [], localOnly: [] };
150
412
  let batchDecision;
151
413
  for (const entry of entries) {
152
414
  const srcRepo = path.join(repoPath, entry.repoRel);
153
415
  if (!fs.existsSync(srcRepo)) {
416
+ // repo 没有但本地有 → 记录为 localOnly
417
+ // jsonFields 条目:srcAbs 是完整 JSON(总是存在),需检查提取字段是否非空
418
+ if (entry.jsonExtract) {
419
+ try {
420
+ const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
421
+ const extracted = jsonField.extractFields(fullJson, entry.jsonExtract.fields);
422
+ if (Object.keys(extracted).length > 0) {
423
+ result.localOnly.push(entry.repoRel);
424
+ }
425
+ }
426
+ catch { /* JSON 解析失败则忽略 */ }
427
+ }
428
+ else if (fs.existsSync(entry.srcAbs)) {
429
+ result.localOnly.push(entry.repoRel);
430
+ }
154
431
  logger.debug(`跳过(仓库中不存在): ${entry.repoRel}`);
155
432
  result.skipped.push(entry.repoRel);
156
433
  continue;
157
434
  }
158
- // ── 冲突检测 ─────────────────────────────────────────────
435
+ // ── JSON 字段级 merge-back ────────────────────────────────
436
+ if (entry.jsonExtract) {
437
+ let partialContent;
438
+ if (entry.encrypt) {
439
+ partialContent = cryptoEngine.decryptString(fs.readFileSync(srcRepo, 'utf-8').trim(), keyPath);
440
+ }
441
+ else {
442
+ partialContent = fs.readFileSync(srcRepo, 'utf-8');
443
+ }
444
+ const partial = JSON.parse(partialContent);
445
+ // 读取本地完整 JSON,merge 进去(不破坏其他字段)
446
+ const targetPath = entry.jsonExtract.originalPath;
447
+ let fullJson = {};
448
+ if (fs.existsSync(targetPath)) {
449
+ fullJson = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
450
+ }
451
+ const merged = jsonField.mergeFields(fullJson, partial);
452
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
453
+ fs.writeFileSync(targetPath, JSON.stringify(merged, null, 2), 'utf-8');
454
+ // shared MCP 条目:同时分发到所有其他 agent
455
+ if (entry.agentName === 'shared' && cfg.shared) {
456
+ for (const source of cfg.shared.mcp.sources) {
457
+ const p = cfg.profiles.default[source.agent];
458
+ if (!p.enabled)
459
+ continue;
460
+ const otherPath = path.join(expandHome(p.workspacePath), source.src);
461
+ if (otherPath === targetPath)
462
+ continue; // 已处理
463
+ let otherJson = {};
464
+ if (fs.existsSync(otherPath)) {
465
+ try {
466
+ otherJson = JSON.parse(fs.readFileSync(otherPath, 'utf-8'));
467
+ }
468
+ catch { /* */ }
469
+ }
470
+ const otherMerged = jsonField.mergeFields(otherJson, partial);
471
+ fs.mkdirSync(path.dirname(otherPath), { recursive: true });
472
+ fs.writeFileSync(otherPath, JSON.stringify(otherMerged, null, 2), 'utf-8');
473
+ }
474
+ }
475
+ result.synced.push(entry.repoRel);
476
+ if (entry.encrypt)
477
+ result.decrypted.push(entry.repoRel);
478
+ continue;
479
+ }
480
+ // ── shared skills 分发到各 agent ──────────────────────────
481
+ if (entry.agentName === 'shared' && entry.repoRel.startsWith('shared/skills/')) {
482
+ const relInSkills = entry.repoRel.slice('shared/skills/'.length);
483
+ const shared = cfg.shared;
484
+ if (shared) {
485
+ for (const source of shared.skills.sources) {
486
+ const p = cfg.profiles.default[source.agent];
487
+ if (!p.enabled)
488
+ continue;
489
+ const dest = path.join(expandHome(p.workspacePath), source.dir, relInSkills);
490
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
491
+ fs.copyFileSync(srcRepo, dest);
492
+ }
493
+ }
494
+ result.synced.push(entry.repoRel);
495
+ continue;
496
+ }
497
+ // ── 冲突检测(整文件同步) ────────────────────────────────
159
498
  if (fs.existsSync(entry.srcAbs)) {
160
499
  let isDiff = false;
161
500
  const localBuf = fs.readFileSync(entry.srcAbs);
@@ -173,17 +512,12 @@ export const syncEngine = {
173
512
  }
174
513
  if (isDiff) {
175
514
  result.conflicts.push(entry.repoRel);
176
- // 已有批量决策则直接应用
177
515
  if (batchDecision === 'skip_all') {
178
516
  logger.info(` ↷ 跳过(保留本地): ${entry.repoRel}`);
179
517
  result.skipped.push(entry.repoRel);
180
518
  continue;
181
519
  }
182
- if (batchDecision === 'overwrite_all') {
183
- // 继续走下方写入逻辑
184
- }
185
- else {
186
- // 无批量决策,询问用户
520
+ if (batchDecision !== 'overwrite_all') {
187
521
  const ans = await askConflict(entry.repoRel);
188
522
  if (ans === 'skip' || ans === 'skip_all') {
189
523
  if (ans === 'skip_all')
@@ -194,7 +528,6 @@ export const syncEngine = {
194
528
  }
195
529
  if (ans === 'overwrite_all')
196
530
  batchDecision = 'overwrite_all';
197
- // ans === 'overwrite' 或 'overwrite_all':继续走下方写入逻辑
198
531
  }
199
532
  }
200
533
  }
@@ -207,7 +540,7 @@ export const syncEngine = {
207
540
  else {
208
541
  fs.copyFileSync(srcRepo, entry.srcAbs);
209
542
  }
210
- result.synced.push(entry.srcAbs);
543
+ result.synced.push(entry.repoRel);
211
544
  }
212
545
  return result;
213
546
  },
@@ -229,6 +562,28 @@ export const syncEngine = {
229
562
  diff.missing.push(entry.repoRel);
230
563
  continue;
231
564
  }
565
+ // JSON 字段提取时,比较提取后的内容
566
+ if (entry.jsonExtract) {
567
+ try {
568
+ const fullJson = JSON.parse(fs.readFileSync(entry.srcAbs, 'utf-8'));
569
+ const localPartial = JSON.stringify(jsonField.extractFields(fullJson, entry.jsonExtract.fields), null, 2);
570
+ let repoContent;
571
+ if (entry.encrypt) {
572
+ repoContent = cryptoEngine.decryptString(fs.readFileSync(path.join(repoPath, entry.repoRel), 'utf-8').trim(), keyPath);
573
+ }
574
+ else {
575
+ repoContent = fs.readFileSync(path.join(repoPath, entry.repoRel), 'utf-8');
576
+ }
577
+ if (localPartial !== repoContent) {
578
+ diff.modified.push(entry.repoRel);
579
+ }
580
+ }
581
+ catch {
582
+ diff.modified.push(entry.repoRel);
583
+ }
584
+ continue;
585
+ }
586
+ // 整文件比较
232
587
  const srcBuf = fs.readFileSync(entry.srcAbs);
233
588
  const repoBuf = fs.readFileSync(path.join(repoPath, entry.repoRel));
234
589
  if (entry.encrypt) {