@mison/ling 1.0.2 → 1.1.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.
@@ -11,13 +11,11 @@ const AtomicWriter = require("./utils/atomic-writer");
11
11
  const CodexBuilder = require("./core/builder");
12
12
  const GeminiAdapter = require("./adapters/gemini");
13
13
  const CodexAdapter = require("./adapters/codex");
14
- const { selectTargets } = require("./interactive");
14
+ const { selectTargets, createConflictPrompter } = require("./interactive");
15
15
 
16
16
  const BUNDLED_AGENT_DIR = path.resolve(__dirname, "../.agents");
17
17
  const BUNDLED_SPEC_DIR = path.resolve(__dirname, "../.spec");
18
18
  const PRIMARY_CLI_NAME = "ling";
19
- const LEGACY_CLI_NAME = "ag-kit";
20
- const CURRENT_CLI_BASENAME = path.basename(process.argv[1] || "", path.extname(process.argv[1] || "")) || PRIMARY_CLI_NAME;
21
19
  const WORKSPACE_INDEX_VERSION = 2;
22
20
  const UPSTREAM_GLOBAL_PACKAGE = "@vudovn/ag-kit";
23
21
  const TOOLKIT_PACKAGE_NAMES = new Set(["@mison/ling", "@mison/ag-kit-cn", "antigravity-kit-cn", "antigravity-kit"]);
@@ -67,36 +65,11 @@ function getVersionTag() {
67
65
  }
68
66
 
69
67
  function getControlHomeDir() {
70
- const preferred = path.join(os.homedir(), ".ling");
71
- const legacy = path.join(os.homedir(), ".ag-kit");
72
-
73
- if (fs.existsSync(preferred)) {
74
- return preferred;
75
- }
76
- if (fs.existsSync(legacy)) {
77
- return legacy;
78
- }
79
- return preferred;
80
- }
81
-
82
- function migrateLegacyControlHomeDir() {
83
- if (process.env.LING_INDEX_PATH || process.env.AG_KIT_INDEX_PATH) {
84
- return null;
85
- }
86
-
87
- const preferred = path.join(os.homedir(), ".ling");
88
- const legacy = path.join(os.homedir(), ".ag-kit");
89
-
90
- if (fs.existsSync(preferred) || !fs.existsSync(legacy)) {
91
- return null;
92
- }
93
-
94
- copyDirRecursive(legacy, preferred);
95
- return { from: legacy, to: preferred };
68
+ return path.join(os.homedir(), ".ling");
96
69
  }
97
70
 
98
71
  function getWorkspaceIndexPath() {
99
- const customPath = process.env.LING_INDEX_PATH || process.env.AG_KIT_INDEX_PATH;
72
+ const customPath = process.env.LING_INDEX_PATH;
100
73
  if (customPath) {
101
74
  return path.resolve(process.cwd(), customPath);
102
75
  }
@@ -113,7 +86,7 @@ function createEmptyWorkspaceIndex() {
113
86
  }
114
87
 
115
88
  function resolveGlobalRootDir() {
116
- const customRoot = process.env.LING_GLOBAL_ROOT || process.env.AG_KIT_GLOBAL_ROOT;
89
+ const customRoot = process.env.LING_GLOBAL_ROOT;
117
90
  if (typeof customRoot === "string" && customRoot.trim()) {
118
91
  return path.resolve(process.cwd(), customRoot);
119
92
  }
@@ -156,6 +129,22 @@ function copyDirRecursive(src, dest) {
156
129
  }
157
130
  }
158
131
 
132
+ function backupWorkspaceDir(workspaceRoot, sourceDir, backupRootName, timestamp, options, label) {
133
+ if (!fs.existsSync(sourceDir)) {
134
+ return "";
135
+ }
136
+ const assetName = path.basename(sourceDir) || "asset";
137
+ const backupDir = path.join(workspaceRoot, backupRootName, timestamp, "preflight", assetName);
138
+ if (options.dryRun) {
139
+ log(options, `[dry-run] 将备份 ${label}: ${sourceDir} -> ${backupDir}`);
140
+ return backupDir;
141
+ }
142
+ fs.mkdirSync(path.dirname(backupDir), { recursive: true });
143
+ copyDirRecursive(sourceDir, backupDir);
144
+ log(options, `[backup] 已备份 ${label}: ${sourceDir} -> ${backupDir}`);
145
+ return backupDir;
146
+ }
147
+
159
148
  function areDirectoriesEqual(leftDir, rightDir) {
160
149
  const left = ManifestManager.generateFromDir(leftDir);
161
150
  const right = ManifestManager.generateFromDir(rightDir);
@@ -428,10 +417,10 @@ function isPathExcludedByList(excludedPaths, workspacePath) {
428
417
 
429
418
  function isToolkitSourceDirectory(workspacePath) {
430
419
  const packageJsonPath = path.join(workspacePath, "package.json");
431
- const legacyCliPath = path.join(workspacePath, "bin", "ag-kit.js");
420
+ const cliPath = path.join(workspacePath, "bin", "ling-cli.js");
432
421
  const primaryCliPath = path.join(workspacePath, "bin", "ling.js");
433
422
 
434
- if (!fs.existsSync(packageJsonPath) || (!fs.existsSync(legacyCliPath) && !fs.existsSync(primaryCliPath))) {
423
+ if (!fs.existsSync(packageJsonPath) || (!fs.existsSync(cliPath) && !fs.existsSync(primaryCliPath))) {
435
424
  return false;
436
425
  }
437
426
 
@@ -856,7 +845,7 @@ function maybeWarnUpstreamGlobalConflict(command, options) {
856
845
  if (options.quiet) {
857
846
  return;
858
847
  }
859
- if (process.env.LING_SKIP_UPSTREAM_CHECK === "1" || process.env.AG_KIT_SKIP_UPSTREAM_CHECK === "1") {
848
+ if (process.env.LING_SKIP_UPSTREAM_CHECK === "1") {
860
849
  return;
861
850
  }
862
851
  const shouldWarn =
@@ -879,20 +868,8 @@ function maybeWarnUpstreamGlobalConflict(command, options) {
879
868
  }
880
869
 
881
870
  log(options, `[warn] 检测到全局已安装上游英文版 ${UPSTREAM_GLOBAL_PACKAGE}。`);
882
- log(options, `[warn] 上游英文版与当前版本共用 \`${LEGACY_CLI_NAME}\` 兼容命令名,后安装者会覆盖该入口。`);
883
- log(options, `[hint] 建议执行: npm uninstall -g ${UPSTREAM_GLOBAL_PACKAGE}`);
884
- log(options, `[info] 正式命令已切换为 \`${PRIMARY_CLI_NAME}\`。若你通过 bun install -g 安装,Bun 默认会阻止本包 postinstall;因此这里会在首次执行 CLI 时再次提醒。`);
885
- }
886
-
887
- function maybeWarnLegacyCliAlias(options) {
888
- if (options.quiet) {
889
- return;
890
- }
891
- if (CURRENT_CLI_BASENAME !== LEGACY_CLI_NAME) {
892
- return;
893
- }
894
-
895
- console.log(`[warn] \`${LEGACY_CLI_NAME}\` 已进入兼容模式,请改用 \`${PRIMARY_CLI_NAME}\`.`);
871
+ log(options, `[hint] 如需仅保留一个来源,请执行: npm uninstall -g ${UPSTREAM_GLOBAL_PACKAGE}`);
872
+ log(options, `[info] 当前项目的正式命令为 \`${PRIMARY_CLI_NAME}\`。`);
896
873
  }
897
874
 
898
875
  function normalizeTargets(rawTargets) {
@@ -1219,9 +1196,106 @@ function syncGlobalSkillsFromRoot(targetName, skillsRoot, timestamp, options) {
1219
1196
  };
1220
1197
  }
1221
1198
 
1199
+ function planGlobalSyncTasks(targetName, agentDir) {
1200
+ const destinations = getGlobalDestinations(targetName);
1201
+
1202
+ if (targetName === "codex") {
1203
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ling-global-codex-"));
1204
+ const mockRoot = path.join(tempRoot, "source");
1205
+ const mockAgent = path.join(mockRoot, ".agents");
1206
+ const outputDir = path.join(tempRoot, "out");
1207
+
1208
+ copyDirRecursive(agentDir, mockAgent);
1209
+ CodexBuilder.build(mockRoot, outputDir);
1210
+ const skillsRoot = path.join(outputDir, "skills");
1211
+ const skillNames = listSkillDirectories(skillsRoot);
1212
+
1213
+ const tasks = [];
1214
+ for (const destination of destinations) {
1215
+ for (const skillName of skillNames) {
1216
+ tasks.push({
1217
+ destination,
1218
+ skillName,
1219
+ srcDir: path.join(skillsRoot, skillName),
1220
+ destDir: path.join(destination.skillsRoot, skillName),
1221
+ });
1222
+ }
1223
+ }
1224
+
1225
+ return {
1226
+ tasks,
1227
+ cleanup: () => fs.rmSync(tempRoot, { recursive: true, force: true }),
1228
+ };
1229
+ }
1230
+
1231
+ if (targetName === "gemini") {
1232
+ const skillsRoot = path.join(agentDir, "skills");
1233
+ const skillNames = listSkillDirectories(skillsRoot);
1234
+ const tasks = [];
1235
+ for (const destination of destinations) {
1236
+ for (const skillName of skillNames) {
1237
+ tasks.push({
1238
+ destination,
1239
+ skillName,
1240
+ srcDir: path.join(skillsRoot, skillName),
1241
+ destDir: path.join(destination.skillsRoot, skillName),
1242
+ });
1243
+ }
1244
+ }
1245
+ return { tasks, cleanup: null };
1246
+ }
1247
+
1248
+ throw new Error(`未知目标: ${targetName}`);
1249
+ }
1250
+
1251
+ function summarizeGlobalSync(tasks) {
1252
+ const perDestination = new Map();
1253
+ let synced = 0;
1254
+ let skipped = 0;
1255
+ let backedUp = 0;
1256
+
1257
+ for (const task of tasks) {
1258
+ const id = task.destination.id;
1259
+ if (!perDestination.has(id)) {
1260
+ perDestination.set(id, {
1261
+ targetName: id,
1262
+ family: task.destination.targetName,
1263
+ destRoot: task.destination.skillsRoot,
1264
+ total: 0,
1265
+ synced: 0,
1266
+ skipped: 0,
1267
+ backedUp: 0,
1268
+ });
1269
+ }
1270
+ const entry = perDestination.get(id);
1271
+ entry.total += 1;
1272
+ if (task.result === "skipped") {
1273
+ skipped += 1;
1274
+ entry.skipped += 1;
1275
+ continue;
1276
+ }
1277
+ if (task.result === "synced") {
1278
+ synced += 1;
1279
+ entry.synced += 1;
1280
+ }
1281
+ if (task.backedUp) {
1282
+ backedUp += 1;
1283
+ entry.backedUp += 1;
1284
+ }
1285
+ }
1286
+
1287
+ return {
1288
+ total: tasks.length,
1289
+ synced,
1290
+ skipped,
1291
+ backedUp,
1292
+ destinations: Array.from(perDestination.values()),
1293
+ };
1294
+ }
1295
+
1222
1296
  function applyGlobalSync(targetName, agentDir, timestamp, options) {
1223
1297
  if (targetName === "codex") {
1224
- const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ag-kit-global-codex-"));
1298
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ling-global-codex-"));
1225
1299
  const mockRoot = path.join(tempRoot, "source");
1226
1300
  const mockAgent = path.join(mockRoot, ".agents");
1227
1301
  const outputDir = path.join(tempRoot, "out");
@@ -1248,21 +1322,70 @@ async function commandGlobalSync(options) {
1248
1322
  const targets = await resolveTargetsForGlobalSync(options);
1249
1323
  const { agentDir, cleanup, sourceLabel } = resolveAgentInstallSource(options);
1250
1324
  const timestamp = nowISO().replace(/[:.]/g, "-");
1325
+ const prompter = createConflictPrompter(options);
1251
1326
 
1252
1327
  try {
1253
1328
  log(options, `[global] 全局同步源: ${sourceLabel}`);
1254
1329
  for (const target of targets) {
1255
1330
  log(options, `[sync] 正在同步全局目标 [${target}] ...`);
1256
- const result = applyGlobalSync(target, agentDir, timestamp, options);
1257
- if (!options.dryRun) {
1258
- log(options, `[summary] 全局同步完成 [${target}]:总计 ${result.total},新增/覆盖 ${result.synced},跳过 ${result.skipped},备份 ${result.backedUp}`);
1259
- for (const item of result.destinations) {
1260
- log(options, ` - ${item.targetName}: ${item.destRoot}(每目标 ${item.total} Skills)`);
1331
+ const plan = planGlobalSyncTasks(target, agentDir);
1332
+ try {
1333
+ for (const task of plan.tasks) {
1334
+ const exists = fs.existsSync(task.destDir);
1335
+ const equal = exists ? areDirectoriesEqual(task.srcDir, task.destDir) : false;
1336
+ if (exists && equal) {
1337
+ task.result = "skipped";
1338
+ continue;
1339
+ }
1340
+
1341
+ if (exists && !equal && prompter) {
1342
+ const action = await prompter.resolveConflict({
1343
+ category: `global:${task.destination.id}`,
1344
+ label: `全局 Skill ${task.destination.id}/${task.skillName}`,
1345
+ path: task.destDir,
1346
+ });
1347
+ task.action = action;
1348
+ } else if (exists && !equal && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
1349
+ // 非交互环境保持原有行为:备份后覆盖
1350
+ task.action = "backup";
1351
+ }
1352
+
1353
+ if (task.action === "keep") {
1354
+ task.result = "skipped";
1355
+ continue;
1356
+ }
1357
+
1358
+ if (options.dryRun) {
1359
+ log(options, `[dry-run] 将同步全局 Skill: ${task.destination.id}/${task.skillName}`);
1360
+ task.result = "skipped";
1361
+ continue;
1362
+ }
1363
+
1364
+ if (exists && task.action !== "remove") {
1365
+ backupSkillDirectory(task.destination.id, task.skillName, task.destDir, timestamp, options);
1366
+ task.backedUp = true;
1367
+ }
1368
+
1369
+ const logger = options.quiet ? (() => {}) : log.bind(null, options);
1370
+ AtomicWriter.atomicCopyDir(task.srcDir, task.destDir, { logger });
1371
+ log(options, `[ok] 已同步全局 Skill: ${task.destination.id}/${task.skillName}`);
1372
+ task.result = "synced";
1373
+ }
1374
+
1375
+ const summary = summarizeGlobalSync(plan.tasks);
1376
+ if (!options.dryRun) {
1377
+ log(options, `[summary] 全局同步完成 [${target}]:总计 ${summary.total},新增/覆盖 ${summary.synced},跳过 ${summary.skipped},备份 ${summary.backedUp}`);
1378
+ for (const item of summary.destinations) {
1379
+ log(options, ` - ${item.targetName}: ${item.destRoot}(每目标 ${item.total} 个 Skills)`);
1380
+ }
1261
1381
  }
1382
+ } finally {
1383
+ if (plan.cleanup) plan.cleanup();
1262
1384
  }
1263
1385
  }
1264
1386
  } finally {
1265
1387
  if (cleanup) cleanup();
1388
+ if (prompter) prompter.close();
1266
1389
  }
1267
1390
  }
1268
1391
 
@@ -1437,7 +1560,7 @@ function removeDirIfExists(targetDir, options, label) {
1437
1560
  log(options, `[clean] 已删除 ${label}: ${targetDir}`);
1438
1561
  }
1439
1562
 
1440
- function ensureSpecAssetsInstalled(state, timestamp, options) {
1563
+ async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
1441
1564
  const specHome = getSpecHomeDir();
1442
1565
  const assets = {
1443
1566
  templates: {
@@ -1455,25 +1578,70 @@ function ensureSpecAssetsInstalled(state, timestamp, options) {
1455
1578
  continue;
1456
1579
  }
1457
1580
 
1458
- const backupPath = backupDirSnapshot(
1459
- config.destDir,
1460
- path.join(resolveSpecBackupRoot(timestamp), "assets", assetName),
1461
- options,
1462
- `Spec ${assetName}`,
1463
- );
1581
+ const exists = fs.existsSync(config.destDir);
1582
+ let action = exists ? "backup" : "";
1583
+ if (exists && prompter) {
1584
+ action = await prompter.resolveConflict({
1585
+ category: "spec:assets",
1586
+ label: `Spec ${assetName}`,
1587
+ path: config.destDir,
1588
+ });
1589
+ }
1590
+
1591
+ if (action === "keep") {
1592
+ log(options, `[skip] 已保留 Spec ${assetName} 资产,不覆盖: ${config.destDir}`);
1593
+ state.assets[assetName] = {
1594
+ destPath: config.destDir,
1595
+ backupPath: "",
1596
+ installedAt: nowISO(),
1597
+ mode: "kept",
1598
+ };
1599
+ continue;
1600
+ }
1601
+
1602
+ let backupPath = "";
1603
+ if (exists && action !== "remove") {
1604
+ backupPath = backupDirSnapshot(
1605
+ config.destDir,
1606
+ path.join(resolveSpecBackupRoot(timestamp), "assets", assetName),
1607
+ options,
1608
+ `Spec ${assetName}`,
1609
+ );
1610
+ } else if (exists && action === "remove") {
1611
+ removeDirIfExists(config.destDir, options, `Spec ${assetName}`);
1612
+ }
1613
+
1464
1614
  applyDirSnapshot(config.sourceDir, config.destDir, options, `Spec ${assetName}`);
1465
1615
  state.assets[assetName] = {
1466
1616
  destPath: config.destDir,
1467
1617
  backupPath,
1468
1618
  installedAt: nowISO(),
1619
+ mode: exists ? (action === "remove" ? "replaced" : "backup") : "created",
1469
1620
  };
1470
1621
  }
1471
1622
  }
1472
1623
 
1624
+ function normalizeSpecAssetMode(assetState) {
1625
+ if (!assetState || typeof assetState !== "object") {
1626
+ return "";
1627
+ }
1628
+ if (typeof assetState.mode === "string" && assetState.mode) {
1629
+ return assetState.mode;
1630
+ }
1631
+ if (typeof assetState.backupPath === "string" && assetState.backupPath) {
1632
+ return "backup";
1633
+ }
1634
+ return "created";
1635
+ }
1636
+
1473
1637
  function restoreSpecAsset(assetState, options, label) {
1474
1638
  if (!assetState || typeof assetState.destPath !== "string") {
1475
1639
  return;
1476
1640
  }
1641
+ const mode = normalizeSpecAssetMode(assetState);
1642
+ if (mode === "kept") {
1643
+ return;
1644
+ }
1477
1645
  if (assetState.backupPath && fs.existsSync(assetState.backupPath)) {
1478
1646
  applyDirSnapshot(assetState.backupPath, assetState.destPath, options, label);
1479
1647
  return;
@@ -1481,7 +1649,7 @@ function restoreSpecAsset(assetState, options, label) {
1481
1649
  removeDirIfExists(assetState.destPath, options, label);
1482
1650
  }
1483
1651
 
1484
- function enableSpecTarget(targetName, state, timestamp, options) {
1652
+ async function enableSpecTarget(targetName, state, timestamp, options, prompter) {
1485
1653
  if (state.targets[targetName]) {
1486
1654
  log(options, `[skip] Spec 目标已启用: ${targetName}`);
1487
1655
  return;
@@ -1501,17 +1669,45 @@ function enableSpecTarget(targetName, state, timestamp, options) {
1501
1669
  for (const skillName of SPEC_SKILL_NAMES) {
1502
1670
  const srcDir = path.join(BUNDLED_SPEC_DIR, "skills", skillName);
1503
1671
  const destDir = path.join(destination.skillsRoot, skillName);
1504
- const backupPath = backupDirSnapshot(
1505
- destDir,
1506
- path.join(resolveSpecBackupRoot(timestamp), destination.id, "skills", skillName),
1507
- options,
1508
- `Spec Skill ${destination.id}/${skillName}`,
1509
- );
1672
+ const exists = fs.existsSync(destDir);
1673
+ let action = exists ? "backup" : "";
1674
+ if (exists && prompter) {
1675
+ action = await prompter.resolveConflict({
1676
+ category: `spec:skills:${destination.id}`,
1677
+ label: `Spec Skill ${destination.id}/${skillName}`,
1678
+ path: destDir,
1679
+ });
1680
+ }
1681
+
1682
+ if (action === "keep") {
1683
+ log(options, `[skip] 已保留 Spec Skill,不覆盖: ${destination.id}/${skillName}`);
1684
+ consumerState.skills.push({
1685
+ name: skillName,
1686
+ destPath: destDir,
1687
+ backupPath: "",
1688
+ mode: "kept",
1689
+ });
1690
+ continue;
1691
+ }
1692
+
1693
+ let backupPath = "";
1694
+ if (exists && action !== "remove") {
1695
+ backupPath = backupDirSnapshot(
1696
+ destDir,
1697
+ path.join(resolveSpecBackupRoot(timestamp), destination.id, "skills", skillName),
1698
+ options,
1699
+ `Spec Skill ${destination.id}/${skillName}`,
1700
+ );
1701
+ } else if (exists && action === "remove") {
1702
+ removeDirIfExists(destDir, options, `Spec Skill ${destination.id}/${skillName}`);
1703
+ }
1704
+
1510
1705
  applyDirSnapshot(srcDir, destDir, options, `Spec Skill ${destination.id}/${skillName}`);
1511
1706
  consumerState.skills.push({
1512
1707
  name: skillName,
1513
1708
  destPath: destDir,
1514
1709
  backupPath,
1710
+ mode: exists ? (action === "remove" ? "replaced" : "backup") : "created",
1515
1711
  });
1516
1712
  }
1517
1713
 
@@ -1530,6 +1726,10 @@ function disableSpecTarget(targetName, state, options) {
1530
1726
 
1531
1727
  for (const [consumerId, consumerState] of Object.entries(targetState.consumers || {})) {
1532
1728
  for (const skill of consumerState.skills || []) {
1729
+ const mode = normalizeSpecAssetMode(skill);
1730
+ if (mode === "kept") {
1731
+ continue;
1732
+ }
1533
1733
  if (skill.backupPath && fs.existsSync(skill.backupPath)) {
1534
1734
  applyDirSnapshot(skill.backupPath, skill.destPath, options, `恢复 Spec Skill ${consumerId}/${skill.name}`);
1535
1735
  } else {
@@ -1608,15 +1808,20 @@ function commandSpecStatus(options) {
1608
1808
  setQuietStatusExitCode(summary.state);
1609
1809
  }
1610
1810
 
1611
- function commandSpecEnable(options) {
1811
+ async function commandSpecEnable(options) {
1612
1812
  ensureBundledSpecResources();
1613
1813
  const targets = resolveTargetsForSpec(options);
1614
1814
  const { statePath, state } = readSpecState();
1615
1815
  const timestamp = nowISO().replace(/[:.]/g, "-");
1816
+ const prompter = createConflictPrompter(options);
1616
1817
 
1617
- ensureSpecAssetsInstalled(state, timestamp, options);
1618
- for (const targetName of targets) {
1619
- enableSpecTarget(targetName, state, timestamp, options);
1818
+ try {
1819
+ await ensureSpecAssetsInstalled(state, timestamp, options, prompter);
1820
+ for (const targetName of targets) {
1821
+ await enableSpecTarget(targetName, state, timestamp, options, prompter);
1822
+ }
1823
+ } finally {
1824
+ if (prompter) prompter.close();
1620
1825
  }
1621
1826
 
1622
1827
  state.updatedAt = nowISO();
@@ -1654,13 +1859,13 @@ function commandSpecDisable(options) {
1654
1859
  log(options, `[ok] Spec Profile 已停用 (Targets: ${targets.join(", ")})`);
1655
1860
  }
1656
1861
 
1657
- function commandSpec(options) {
1862
+ async function commandSpec(options) {
1658
1863
  const subcommand = String(options.subcommand || "status").toLowerCase();
1659
1864
  if (subcommand === "status") {
1660
1865
  return commandSpecStatus(options);
1661
1866
  }
1662
1867
  if (subcommand === "enable") {
1663
- return commandSpecEnable(options);
1868
+ return await commandSpecEnable(options);
1664
1869
  }
1665
1870
  if (subcommand === "disable") {
1666
1871
  return commandSpecDisable(options);
@@ -1671,12 +1876,83 @@ function commandSpec(options) {
1671
1876
  async function commandInit(options) {
1672
1877
  const workspaceRoot = resolveWorkspaceRoot(options.path);
1673
1878
  const targets = await resolveTargetsForInit(options);
1879
+ const prompter = createConflictPrompter(options);
1674
1880
 
1675
- for (const target of targets) {
1676
- const adapter = createAdapter(target, workspaceRoot, options);
1677
- log(options, `[sync] 正在初始化目标 [${target}] ...`);
1678
- adapter.install(BUNDLED_AGENT_DIR);
1679
- registerWorkspaceTarget(workspaceRoot, target, options);
1881
+ try {
1882
+ for (const target of targets) {
1883
+ const runOptions = { ...options };
1884
+ const conflicts = [];
1885
+
1886
+ if (target === "gemini") {
1887
+ const agentDir = path.join(workspaceRoot, ".agent");
1888
+ if (fs.existsSync(agentDir)) {
1889
+ conflicts.push({
1890
+ category: "project:gemini",
1891
+ label: ".agent",
1892
+ path: agentDir,
1893
+ target,
1894
+ });
1895
+ }
1896
+ }
1897
+
1898
+ if (target === "codex") {
1899
+ const managedDir = path.join(workspaceRoot, ".agents");
1900
+ const legacyDir = path.join(workspaceRoot, ".codex");
1901
+ if (fs.existsSync(managedDir) || fs.existsSync(legacyDir)) {
1902
+ if (fs.existsSync(managedDir)) {
1903
+ conflicts.push({
1904
+ category: "project:codex",
1905
+ label: ".agents",
1906
+ path: managedDir,
1907
+ target,
1908
+ });
1909
+ }
1910
+ if (fs.existsSync(legacyDir)) {
1911
+ conflicts.push({
1912
+ category: "project:codex",
1913
+ label: ".codex",
1914
+ path: legacyDir,
1915
+ target,
1916
+ });
1917
+ }
1918
+ }
1919
+ }
1920
+
1921
+ if (conflicts.length > 0) {
1922
+ if (!prompter && !runOptions.force) {
1923
+ throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。");
1924
+ }
1925
+
1926
+ const timestamp = nowISO().replace(/[:.]/g, "-");
1927
+ let shouldSkip = false;
1928
+
1929
+ for (const conflict of conflicts) {
1930
+ const action = prompter ? await prompter.resolveConflict(conflict) : "backup";
1931
+ if (action === "keep") {
1932
+ log(options, `[skip] 已保留现有资产,跳过初始化: ${conflict.label}`);
1933
+ shouldSkip = true;
1934
+ break;
1935
+ }
1936
+ if (action === "backup") {
1937
+ const backupRootName = conflict.label === ".agent" ? ".agent-backup" : ".agents-backup";
1938
+ backupWorkspaceDir(workspaceRoot, conflict.path, backupRootName, timestamp, options, `工作区资产 ${conflict.label}`);
1939
+ }
1940
+ // remove/backup 都需要强制覆盖才能继续
1941
+ runOptions.force = true;
1942
+ }
1943
+
1944
+ if (shouldSkip) {
1945
+ continue;
1946
+ }
1947
+ }
1948
+
1949
+ const adapter = createAdapter(target, workspaceRoot, runOptions);
1950
+ log(options, `[sync] 正在初始化目标 [${target}] ...`);
1951
+ adapter.install(BUNDLED_AGENT_DIR);
1952
+ registerWorkspaceTarget(workspaceRoot, target, runOptions);
1953
+ }
1954
+ } finally {
1955
+ if (prompter) prompter.close();
1680
1956
  }
1681
1957
 
1682
1958
  if (targets.length > 0) {
@@ -1684,9 +1960,62 @@ async function commandInit(options) {
1684
1960
  }
1685
1961
  }
1686
1962
 
1963
+ function evaluateCodexUpdateConflict(activeDir) {
1964
+ const manifestPath = path.join(activeDir, "manifest.json");
1965
+ const details = {
1966
+ hasConflict: false,
1967
+ manifestMissing: false,
1968
+ modifiedCount: 0,
1969
+ unknownCount: 0,
1970
+ };
1971
+
1972
+ if (!fs.existsSync(manifestPath)) {
1973
+ details.hasConflict = true;
1974
+ details.manifestMissing = true;
1975
+ return details;
1976
+ }
1977
+
1978
+ try {
1979
+ const manager = new ManifestManager(manifestPath, { target: "codex" });
1980
+ manager.load();
1981
+ const drift = manager.checkDrift(activeDir);
1982
+ if (drift.modified.length > 0) {
1983
+ details.hasConflict = true;
1984
+ details.modifiedCount = drift.modified.length;
1985
+ }
1986
+
1987
+ const manifestFiles = manager.manifest.files || {};
1988
+ const stack = [activeDir];
1989
+ while (stack.length > 0) {
1990
+ const dir = stack.pop();
1991
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1992
+ for (const entry of entries) {
1993
+ const fullPath = path.join(dir, entry.name);
1994
+ if (entry.isDirectory()) {
1995
+ stack.push(fullPath);
1996
+ continue;
1997
+ }
1998
+ const relPath = path.relative(activeDir, fullPath).split(path.sep).join("/");
1999
+ if (!manifestFiles[relPath]) {
2000
+ details.unknownCount += 1;
2001
+ }
2002
+ }
2003
+ }
2004
+ if (details.unknownCount > 0) {
2005
+ details.hasConflict = true;
2006
+ }
2007
+ } catch (err) {
2008
+ details.hasConflict = true;
2009
+ details.manifestMissing = true;
2010
+ }
2011
+
2012
+ return details;
2013
+ }
2014
+
1687
2015
  async function commandUpdate(options) {
1688
2016
  const workspaceRoot = resolveWorkspaceRoot(options.path);
1689
2017
  const targets = resolveTargetsForUpdate(workspaceRoot, options);
2018
+ const prompter = createConflictPrompter(options);
1690
2019
 
1691
2020
  if (targets.length === 0) {
1692
2021
  throw new Error(`此目录未检测到 ${PRIMARY_CLI_NAME} 安装,无法更新。请先执行 init。`);
@@ -1695,7 +2024,8 @@ async function commandUpdate(options) {
1695
2024
  log(options, `[update] 正在更新 Ling (Targets: ${targets.join(", ")})...`);
1696
2025
 
1697
2026
  let updatedAny = false;
1698
- for (const target of targets) {
2027
+ try {
2028
+ for (const target of targets) {
1699
2029
  if (!isTargetInstalled(workspaceRoot, target) && options.targets.length > 0) {
1700
2030
  throw new Error(`目标未安装: ${target}`);
1701
2031
  }
@@ -1705,15 +2035,69 @@ async function commandUpdate(options) {
1705
2035
  }
1706
2036
 
1707
2037
  const runOptions = { ...options, force: true };
2038
+
2039
+ const timestamp = nowISO().replace(/[:.]/g, "-");
2040
+ if (target === "gemini") {
2041
+ const agentDir = path.join(workspaceRoot, ".agent");
2042
+ if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
2043
+ let action = "backup";
2044
+ if (prompter) {
2045
+ action = await prompter.resolveConflict({
2046
+ category: "project:gemini",
2047
+ label: ".agent",
2048
+ path: agentDir,
2049
+ target,
2050
+ });
2051
+ }
2052
+ if (action === "keep") {
2053
+ log(options, "[skip] 已保留现有资产,跳过更新: .agent");
2054
+ continue;
2055
+ }
2056
+ if (action === "backup") {
2057
+ backupWorkspaceDir(workspaceRoot, agentDir, ".agent-backup", timestamp, options, "工作区资产 .agent");
2058
+ }
2059
+ }
2060
+ }
2061
+
2062
+ if (target === "codex") {
2063
+ const managedDir = path.join(workspaceRoot, ".agents");
2064
+ const legacyDir = path.join(workspaceRoot, ".codex");
2065
+ const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
2066
+ const conflict = evaluateCodexUpdateConflict(activeDir);
2067
+ if (conflict.hasConflict) {
2068
+ let action = "backup";
2069
+ if (prompter) {
2070
+ action = await prompter.resolveConflict({
2071
+ category: "project:codex",
2072
+ label: fs.existsSync(managedDir) ? ".agents" : ".codex",
2073
+ path: activeDir,
2074
+ target,
2075
+ });
2076
+ }
2077
+ if (action === "keep") {
2078
+ log(options, `[skip] 已保留现有资产,跳过更新: ${path.basename(activeDir)}`);
2079
+ continue;
2080
+ }
2081
+ if (action === "backup") {
2082
+ const backupRootName = ".agents-backup";
2083
+ backupWorkspaceDir(workspaceRoot, activeDir, backupRootName, timestamp, options, `工作区资产 ${path.basename(activeDir)}`);
2084
+ }
2085
+ }
2086
+ }
2087
+
1708
2088
  const adapter = createAdapter(target, workspaceRoot, runOptions);
1709
2089
  log(options, `[sync] 更新 [${target}] ...`);
1710
2090
  adapter.update(BUNDLED_AGENT_DIR);
1711
2091
  registerWorkspaceTarget(workspaceRoot, target, runOptions);
1712
2092
  updatedAny = true;
2093
+ }
2094
+ } finally {
2095
+ if (prompter) prompter.close();
1713
2096
  }
1714
2097
 
1715
2098
  if (!updatedAny) {
1716
- throw new Error("未找到可更新的目标");
2099
+ log(options, "[skip] 未执行更新(已保留现有资产)");
2100
+ return;
1717
2101
  }
1718
2102
  }
1719
2103
 
@@ -1754,6 +2138,7 @@ async function commandUpdateAll(options) {
1754
2138
 
1755
2139
  log(options, `[update] 开始批量更新工作区(共 ${records.length} 个)...`);
1756
2140
  log(options, `[index] 索引文件: ${indexPath}`);
2141
+ const prompter = createConflictPrompter(options);
1757
2142
 
1758
2143
  let updated = 0;
1759
2144
  let skipped = 0;
@@ -1764,7 +2149,8 @@ async function commandUpdateAll(options) {
1764
2149
  const nextRecords = [];
1765
2150
  const removedRecordKeys = new Set();
1766
2151
 
1767
- for (let i = 0; i < records.length; i++) {
2152
+ try {
2153
+ for (let i = 0; i < records.length; i++) {
1768
2154
  const item = normalizeWorkspaceRecordV2(records[i], normalizeAbsolutePath(records[i].path));
1769
2155
  const workspacePath = normalizeAbsolutePath(item.path);
1770
2156
  const exclusion = evaluateWorkspaceExclusion(index, workspacePath);
@@ -1825,6 +2211,54 @@ async function commandUpdateAll(options) {
1825
2211
  path: workspacePath,
1826
2212
  silentIndexLog: true,
1827
2213
  };
2214
+
2215
+ const timestampForBackup = nowISO().replace(/[:.]/g, "-");
2216
+
2217
+ if (target === "gemini") {
2218
+ const agentDir = path.join(workspacePath, ".agent");
2219
+ if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
2220
+ let action = "backup";
2221
+ if (prompter) {
2222
+ action = await prompter.resolveConflict({
2223
+ category: "update-all:project:gemini",
2224
+ label: `.agent (${workspacePath})`,
2225
+ path: agentDir,
2226
+ });
2227
+ }
2228
+ if (action === "keep") {
2229
+ log(options, `[skip] [${i + 1}/${records.length}] 已保留现有资产,跳过更新: ${workspacePath} [gemini]`);
2230
+ continue;
2231
+ }
2232
+ if (action === "backup") {
2233
+ backupWorkspaceDir(workspacePath, agentDir, ".agent-backup", timestampForBackup, options, `工作区资产 .agent (${workspacePath})`);
2234
+ }
2235
+ }
2236
+ }
2237
+
2238
+ if (target === "codex") {
2239
+ const managedDir = path.join(workspacePath, ".agents");
2240
+ const legacyDir = path.join(workspacePath, ".codex");
2241
+ const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
2242
+ const conflict = evaluateCodexUpdateConflict(activeDir);
2243
+ if (conflict.hasConflict) {
2244
+ let action = "backup";
2245
+ if (prompter) {
2246
+ action = await prompter.resolveConflict({
2247
+ category: "update-all:project:codex",
2248
+ label: `${path.basename(activeDir)} (${workspacePath})`,
2249
+ path: activeDir,
2250
+ });
2251
+ }
2252
+ if (action === "keep") {
2253
+ log(options, `[skip] [${i + 1}/${records.length}] 已保留现有资产,跳过更新: ${workspacePath} [codex]`);
2254
+ continue;
2255
+ }
2256
+ if (action === "backup") {
2257
+ backupWorkspaceDir(workspacePath, activeDir, ".agents-backup", timestampForBackup, options, `工作区资产 ${path.basename(activeDir)} (${workspacePath})`);
2258
+ }
2259
+ }
2260
+ }
2261
+
1828
2262
  const adapter = createAdapter(target, workspacePath, runOptions);
1829
2263
  adapter.update(BUNDLED_AGENT_DIR);
1830
2264
  updatedTargets.push(target);
@@ -1844,44 +2278,49 @@ async function commandUpdateAll(options) {
1844
2278
  skipped += 1;
1845
2279
  nextRecords.push(item);
1846
2280
  }
1847
- }
2281
+ }
1848
2282
 
1849
- if (!options.dryRun) {
1850
- withWorkspaceIndexLock(indexPath, () => {
1851
- const { index: latestIndex } = readWorkspaceIndex();
1852
- const mergedMap = new Map();
2283
+ if (!options.dryRun) {
2284
+ withWorkspaceIndexLock(indexPath, () => {
2285
+ const { index: latestIndex } = readWorkspaceIndex();
2286
+ const mergedMap = new Map();
1853
2287
 
1854
- for (const item of latestIndex.workspaces || []) {
1855
- if (!item || typeof item.path !== "string") continue;
1856
- mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
1857
- }
2288
+ for (const item of latestIndex.workspaces || []) {
2289
+ if (!item || typeof item.path !== "string") continue;
2290
+ mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
2291
+ }
1858
2292
 
1859
- for (const removedKey of removedRecordKeys) {
1860
- mergedMap.delete(removedKey);
1861
- }
2293
+ for (const removedKey of removedRecordKeys) {
2294
+ mergedMap.delete(removedKey);
2295
+ }
1862
2296
 
1863
- for (const item of nextRecords) {
1864
- if (!item || typeof item.path !== "string") continue;
1865
- mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
1866
- }
2297
+ for (const item of nextRecords) {
2298
+ if (!item || typeof item.path !== "string") continue;
2299
+ mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
2300
+ }
1867
2301
 
1868
- latestIndex.workspaces = Array.from(mergedMap.values()).sort((a, b) => a.path.localeCompare(b.path));
1869
- latestIndex.updatedAt = timestamp;
1870
- writeWorkspaceIndex(indexPath, latestIndex);
1871
- });
1872
- }
2302
+ latestIndex.workspaces = Array.from(mergedMap.values()).sort((a, b) => a.path.localeCompare(b.path));
2303
+ latestIndex.updatedAt = timestamp;
2304
+ writeWorkspaceIndex(indexPath, latestIndex);
2305
+ });
2306
+ }
1873
2307
 
1874
- log(options, "[summary] 批量更新完成");
1875
- log(options, ` 成功: ${updated}`);
1876
- log(options, ` 跳过: ${skipped}`);
1877
- log(options, ` 失败: ${failed}`);
1878
- log(options, ` 清理排除路径: ${removedExcluded}`);
1879
- if (options.pruneMissing) {
1880
- log(options, ` 清理失效索引: ${removedMissing}`);
1881
- }
2308
+ log(options, "[summary] 批量更新完成");
2309
+ log(options, ` 成功: ${updated}`);
2310
+ log(options, ` 跳过: ${skipped}`);
2311
+ log(options, ` 失败: ${failed}`);
2312
+ log(options, ` 清理排除路径: ${removedExcluded}`);
2313
+ if (options.pruneMissing) {
2314
+ log(options, ` 清理失效索引: ${removedMissing}`);
2315
+ }
1882
2316
 
1883
- if (failed > 0) {
1884
- process.exitCode = 1;
2317
+ if (failed > 0) {
2318
+ process.exitCode = 1;
2319
+ }
2320
+ } finally {
2321
+ if (prompter) {
2322
+ prompter.close();
2323
+ }
1885
2324
  }
1886
2325
  }
1887
2326
 
@@ -2191,14 +2630,8 @@ function commandStatus(options) {
2191
2630
 
2192
2631
  async function main() {
2193
2632
  try {
2194
- const migration = migrateLegacyControlHomeDir();
2195
2633
  const { command, options, providedFlags } = parseArgs(process.argv.slice(2));
2196
2634
 
2197
- if (migration && !options.quiet) {
2198
- console.log(`[migrate] 已迁移控制目录: ${migration.from} -> ${migration.to}`);
2199
- }
2200
- maybeWarnLegacyCliAlias(options);
2201
-
2202
2635
  if (!command || command === "--help" || command === "-h") {
2203
2636
  printUsage();
2204
2637
  if (!command || command === "--help" || command === "-h") {
@@ -2240,7 +2673,7 @@ async function main() {
2240
2673
  }
2241
2674
 
2242
2675
  if (command === "spec") {
2243
- commandSpec(options);
2676
+ await commandSpec(options);
2244
2677
  return;
2245
2678
  }
2246
2679