@mison/ling 1.2.0 → 1.2.3

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/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [ling-1.2.3] - 2026-03-15
11
+
12
+ ### 修复
13
+
14
+ - 修复 Gemini CLI 全局 Skill 冲突:当 `~/.agents/skills/` 已提供同名 universal Skill 时,`ling global sync --target gemini` 与默认全局同步会自动备份并清理 `~/.gemini/skills/` 中的重复副本,避免 Gemini 启动时出现 `Skill conflict detected`。
15
+
16
+ ## [ling-1.2.2] - 2026-03-14
17
+
18
+ ### 修复
19
+
20
+ - 修复全局 Skills 重复:当检测到旧版 `~/.codex/skills/` 时,同步 `codex` 会迁移到 `~/.agents/skills/` 并清理遗留目录;若与现有目录冲突则备份到 `~/.ling/backups/global/<timestamp>/codex-legacy/...`。
21
+
22
+ ## [ling-1.2.1] - 2026-03-14
23
+
24
+ ### 修复
25
+
26
+ - 修正 Codex 全局 Skill 安装根目录:`ling global sync --target codex` 与 `ling spec enable --target codex` 现在统一写入官方要求的 `~/.agents/skills/`,不再误写到旧的 `~/.codex/skills/`。
27
+ - 兼容旧版 Spec 状态:历史 `state.json` 中记录的 `~/.codex/skills/...` 路径会在读取时自动迁移为 `~/.agents/skills/...`,避免修复后仍引用旧路径。
28
+ - 增加旧路径诊断:若用户机器上只存在旧版 `~/.codex/skills/`,`ling global status` 会返回 `broken` 并提示执行 `ling global sync --target codex` 修复。
29
+
10
30
  ## [ling-1.2.0] - 2026-03-14
11
31
 
12
32
  ### 新增
@@ -98,7 +118,8 @@
98
118
 
99
119
  本项目在 Ling 重启前的 2.x/3.x 版本记录已冻结,不再维护。
100
120
 
101
- [Unreleased]: https://github.com/MisonL/Ling/compare/ling-1.2.0...HEAD
121
+ [Unreleased]: https://github.com/MisonL/Ling/compare/ling-1.2.1...HEAD
122
+ [ling-1.2.1]: https://github.com/MisonL/Ling/releases/tag/ling-1.2.1
102
123
  [ling-1.2.0]: https://github.com/MisonL/Ling/releases/tag/ling-1.2.0
103
124
  [ling-1.1.1]: https://github.com/MisonL/Ling/releases/tag/ling-1.1.1
104
125
  [ling-1.1.0]: https://github.com/MisonL/Ling/releases/tag/ling-1.1.0
package/README.md CHANGED
@@ -47,7 +47,7 @@ ling global sync --target antigravity
47
47
  | 你要做什么 | 命令 | 结果 |
48
48
  | --- | --- | --- |
49
49
  | 给当前项目安装完整资产 | `ling init` | 项目内生成 `.agent/` / `.agents/`;共享 `.agent/` 时会维护 `.ling/install-state.json` |
50
- | 给电脑全局同步可复用 Skills | `ling global sync` | 写入 `~/.codex/skills/`、`~/.gemini/skills/` |
50
+ | 给电脑全局同步可复用 Skills | `ling global sync` | 写入 `~/.agents/skills/`、`~/.gemini/antigravity/skills/` 等,并自动清理 Gemini CLI 的重复副本 |
51
51
  | 给项目启用 Spec 工作流 | `ling spec init` | 项目内生成 `issues.csv` 等 Spec 资产 |
52
52
 
53
53
  一句话区分:
@@ -89,10 +89,12 @@ ling global sync --target antigravity
89
89
  ling global status
90
90
  ```
91
91
 
92
- - `codex` -> `~/.codex/skills/`
93
- - `gemini` -> `~/.gemini/skills/`
92
+ - `codex` -> `~/.agents/skills/`
93
+ - `gemini` -> `~/.gemini/skills/`(若已存在 `~/.agents/skills/`,会自动移除重复副本,避免 Gemini CLI 冲突提示)
94
94
  - `antigravity` -> `~/.gemini/antigravity/skills/`
95
95
 
96
+ 若检测到旧版 `~/.codex/skills/`,`ling global sync --target codex` 会将其迁移到 `~/.agents/skills/` 并清理,避免 Skills 重复(冲突内容会先备份到 `~/.ling/backups/global/<timestamp>/codex-legacy/...`)。
97
+
96
98
  全局模式不会写入项目 Rules、Agents、Workflows,也不会改你的全局 `~/.codex/rules`。
97
99
 
98
100
  ## Spec
package/bin/ling-cli.js CHANGED
@@ -28,8 +28,8 @@ const GLOBAL_TARGET_DESTINATIONS = {
28
28
  codex: [
29
29
  {
30
30
  id: "codex",
31
- rootParts: [".codex"],
32
- skillsParts: [".codex", "skills"],
31
+ rootParts: [".agents"],
32
+ skillsParts: [".agents", "skills"],
33
33
  },
34
34
  ],
35
35
  gemini: [
@@ -61,6 +61,8 @@ const VERSION_TAG_PREFIX = "ling-";
61
61
  const SPEC_TEMPLATE_REQUIRED_FILES = ["issues.template.csv", "driver-prompt.md", "review-report.md", "phase-acceptance.md", "handoff.md"];
62
62
  const SPEC_REFERENCE_REQUIRED_FILES = ["README.md", "harness-engineering-digest.md", "gda-framework.md", "cse-quickstart.md"];
63
63
  const SPEC_PROFILE_REQUIRED_FILES = ["codex/AGENTS.spec.md", "codex/ling.spec.rules.md", "gemini/GEMINI.spec.md"];
64
+ const LEGACY_CODEX_GLOBAL_SKILLS_BACKUP_TARGET = "codex-legacy";
65
+ const REDUNDANT_GEMINI_GLOBAL_SKILLS_BACKUP_TARGET = "gemini-cli-redundant";
64
66
 
65
67
  function nowISO() {
66
68
  return new Date().toISOString();
@@ -1154,6 +1156,22 @@ function evaluateGlobalState() {
1154
1156
  };
1155
1157
  }).filter((item) => item.state !== "missing");
1156
1158
 
1159
+ const legacyCodexSkillsRoot = path.join(globalRoot, ".codex", "skills");
1160
+ const hasCurrentCodexRoot = targetStates.some((item) => item.targetName === "codex");
1161
+ if (!hasCurrentCodexRoot && fs.existsSync(legacyCodexSkillsRoot)) {
1162
+ targetStates.push({
1163
+ targetName: "codex",
1164
+ family: "codex",
1165
+ state: "broken",
1166
+ rootDir: path.join(globalRoot, ".codex"),
1167
+ skillsRoot: legacyCodexSkillsRoot,
1168
+ skillsCount: countSkillsRecursive(legacyCodexSkillsRoot),
1169
+ issues: [
1170
+ "检测到旧版 Codex 全局 Skill 目录;当前版本改用 ~/.agents/skills(运行: ling global sync --target codex)",
1171
+ ],
1172
+ });
1173
+ }
1174
+
1157
1175
  if (targetStates.length === 0) {
1158
1176
  return {
1159
1177
  globalRoot,
@@ -1271,6 +1289,120 @@ function backupSkillDirectory(targetName, skillName, sourceDir, timestamp, optio
1271
1289
  log(options, `[backup] 已备份 ${targetName} 全局 Skill: ${skillName} -> ${backupDir}`);
1272
1290
  }
1273
1291
 
1292
+ function migrateLegacyCodexGlobalSkills(timestamp, options) {
1293
+ const globalRoot = resolveGlobalRootDir();
1294
+ const legacyRoot = path.join(globalRoot, ".codex", "skills");
1295
+ if (!fs.existsSync(legacyRoot)) {
1296
+ return { migrated: 0, removed: 0, backedUp: 0, conflicts: 0, remaining: 0 };
1297
+ }
1298
+
1299
+ const destRoot = path.join(globalRoot, ".agents", "skills");
1300
+ const legacyDirEntries = fs.readdirSync(legacyRoot, { withFileTypes: true });
1301
+ const legacyEntries = legacyDirEntries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1302
+ if (legacyEntries.length === 0) {
1303
+ if (legacyDirEntries.length === 0) {
1304
+ removeDirIfExists(legacyRoot, options, "遗留 Codex skills 根目录");
1305
+ } else {
1306
+ log(options, `[warn] 遗留 Codex skills 根目录包含非目录条目,已跳过迁移清理: ${legacyRoot}`);
1307
+ }
1308
+ return { migrated: 0, removed: 0, backedUp: 0, conflicts: 0, remaining: legacyDirEntries.length };
1309
+ }
1310
+
1311
+ let migrated = 0;
1312
+ let removed = 0;
1313
+ let backedUp = 0;
1314
+ let conflicts = 0;
1315
+
1316
+ for (const entryName of legacyEntries) {
1317
+ const legacyDir = path.join(legacyRoot, entryName);
1318
+ const destDir = path.join(destRoot, entryName);
1319
+ const destExists = fs.existsSync(destDir);
1320
+
1321
+ if (!destExists) {
1322
+ if (options.dryRun) {
1323
+ log(options, `[dry-run] 将迁移遗留 Codex Skill 目录: ${legacyDir} -> ${destDir}`);
1324
+ } else {
1325
+ const logger = options.quiet ? (() => {}) : log.bind(null, options);
1326
+ AtomicWriter.atomicCopyDir(legacyDir, destDir, { logger });
1327
+ log(options, `[ok] 已迁移遗留 Codex Skill 目录: ${legacyDir} -> ${destDir}`);
1328
+ }
1329
+ migrated += 1;
1330
+ } else if (!areDirectoriesEqual(legacyDir, destDir)) {
1331
+ conflicts += 1;
1332
+ if (options.dryRun) {
1333
+ log(options, `[dry-run] 将备份并移除遗留 Codex Skill(与现有目录冲突): ${legacyDir}`);
1334
+ } else {
1335
+ backupSkillDirectory(LEGACY_CODEX_GLOBAL_SKILLS_BACKUP_TARGET, entryName, legacyDir, timestamp, options);
1336
+ backedUp += 1;
1337
+ }
1338
+ }
1339
+
1340
+ if (options.dryRun) {
1341
+ log(options, `[dry-run] 将删除遗留 Codex Skill 目录: ${legacyDir}`);
1342
+ continue;
1343
+ }
1344
+
1345
+ fs.rmSync(legacyDir, { recursive: true, force: true });
1346
+ log(options, `[clean] 已删除遗留 Codex Skill 目录: ${legacyDir}`);
1347
+ removed += 1;
1348
+ }
1349
+
1350
+ const remaining = fs.existsSync(legacyRoot) ? fs.readdirSync(legacyRoot).length : 0;
1351
+ if (remaining === 0) {
1352
+ removeDirIfExists(legacyRoot, options, "遗留 Codex skills 根目录");
1353
+ } else {
1354
+ log(options, `[warn] 遗留 Codex skills 根目录仍有残留条目(${remaining}),已保留: ${legacyRoot}`);
1355
+ }
1356
+
1357
+ return { migrated, removed, backedUp, conflicts, remaining };
1358
+ }
1359
+
1360
+ function reconcileGeminiGlobalSkillsAgainstUniversalRoot(timestamp, options) {
1361
+ const [codexDestination] = getGlobalDestinations("codex");
1362
+ const [geminiDestination] = getGlobalDestinations("gemini");
1363
+ const universalRoot = codexDestination.skillsRoot;
1364
+ const geminiRoot = geminiDestination.skillsRoot;
1365
+
1366
+ if (!fs.existsSync(universalRoot) || !fs.existsSync(geminiRoot)) {
1367
+ return { removed: 0, backedUp: 0 };
1368
+ }
1369
+
1370
+ let removed = 0;
1371
+ let backedUp = 0;
1372
+ for (const skillName of listSkillDirectories(geminiRoot)) {
1373
+ const geminiSkillDir = path.join(geminiRoot, skillName);
1374
+ const universalSkillDir = path.join(universalRoot, skillName);
1375
+ if (!fs.existsSync(universalSkillDir)) {
1376
+ continue;
1377
+ }
1378
+
1379
+ if (options.dryRun) {
1380
+ log(options, `[dry-run] 将移除 Gemini CLI 重复 Skill(由 universal 根目录提供): ${geminiSkillDir}`);
1381
+ removed += 1;
1382
+ continue;
1383
+ }
1384
+
1385
+ backupSkillDirectory(
1386
+ REDUNDANT_GEMINI_GLOBAL_SKILLS_BACKUP_TARGET,
1387
+ skillName,
1388
+ geminiSkillDir,
1389
+ timestamp,
1390
+ options,
1391
+ );
1392
+ backedUp += 1;
1393
+ fs.rmSync(geminiSkillDir, { recursive: true, force: true });
1394
+ log(options, `[clean] 已移除 Gemini CLI 重复 Skill: ${geminiSkillDir}`);
1395
+ removed += 1;
1396
+ }
1397
+
1398
+ if (!options.dryRun && fs.existsSync(geminiRoot) && fs.readdirSync(geminiRoot).length === 0) {
1399
+ fs.rmSync(geminiRoot, { recursive: true, force: true });
1400
+ log(options, `[clean] 已删除空的 Gemini CLI skills 根目录: ${geminiRoot}`);
1401
+ }
1402
+
1403
+ return { removed, backedUp };
1404
+ }
1405
+
1274
1406
  function syncSkillDirectory(destination, srcDir, destDir, timestamp, options) {
1275
1407
  const exists = fs.existsSync(destDir);
1276
1408
  if (exists) {
@@ -1537,6 +1669,14 @@ async function commandGlobalSync(options) {
1537
1669
  log(options, ` - ${item.targetName}: ${item.destRoot}(每目标 ${item.total} 个 Skills)`);
1538
1670
  }
1539
1671
  }
1672
+
1673
+ if (target === "codex") {
1674
+ migrateLegacyCodexGlobalSkills(timestamp, options);
1675
+ reconcileGeminiGlobalSkillsAgainstUniversalRoot(timestamp, options);
1676
+ }
1677
+ if (target === "gemini") {
1678
+ reconcileGeminiGlobalSkillsAgainstUniversalRoot(timestamp, options);
1679
+ }
1540
1680
  } finally {
1541
1681
  if (plan.cleanup) plan.cleanup();
1542
1682
  }
@@ -1626,15 +1766,121 @@ function createEmptySpecState() {
1626
1766
  };
1627
1767
  }
1628
1768
 
1769
+ function rewriteLegacyCodexGlobalSkillPath(targetPath) {
1770
+ if (typeof targetPath !== "string" || targetPath === "") {
1771
+ return targetPath;
1772
+ }
1773
+ const normalizedPath = targetPath.replace(/\\/g, "/");
1774
+ if (!normalizedPath.includes("/.codex/skills")) {
1775
+ return targetPath;
1776
+ }
1777
+ return targetPath.replace(/([\\/])\.codex([\\/])skills/g, "$1.agents$2skills");
1778
+ }
1779
+
1780
+ function normalizeSpecSkillState(targetName, consumerId, skillState) {
1781
+ if (!skillState || typeof skillState !== "object" || typeof skillState.name !== "string" || !skillState.name) {
1782
+ return null;
1783
+ }
1784
+ const destPath = typeof skillState.destPath === "string" ? skillState.destPath : "";
1785
+ return {
1786
+ name: skillState.name,
1787
+ destPath: targetName === "codex" && consumerId === "codex"
1788
+ ? rewriteLegacyCodexGlobalSkillPath(destPath)
1789
+ : destPath,
1790
+ backupPath: typeof skillState.backupPath === "string" ? skillState.backupPath : "",
1791
+ mode: normalizeSpecAssetMode(skillState),
1792
+ };
1793
+ }
1794
+
1795
+ function normalizeSpecConsumerState(targetName, consumerId, consumerState) {
1796
+ const skills = [];
1797
+ let migrated = false;
1798
+ for (const skillState of Array.isArray(consumerState && consumerState.skills) ? consumerState.skills : []) {
1799
+ const normalizedSkill = normalizeSpecSkillState(targetName, consumerId, skillState);
1800
+ if (normalizedSkill) {
1801
+ skills.push(normalizedSkill);
1802
+ if (normalizedSkill.destPath !== (typeof skillState.destPath === "string" ? skillState.destPath : "")) {
1803
+ migrated = true;
1804
+ }
1805
+ }
1806
+ }
1807
+ return { skills, migrated };
1808
+ }
1809
+
1810
+ function normalizeSpecTargetState(targetName, targetState) {
1811
+ const allowedConsumers = new Set(getGlobalDestinations(targetName).map((destination) => destination.id));
1812
+ const consumers = {};
1813
+ let migrated = false;
1814
+ for (const consumerId of allowedConsumers) {
1815
+ if (targetState && targetState.consumers && targetState.consumers[consumerId]) {
1816
+ const normalizedConsumerState = normalizeSpecConsumerState(targetName, consumerId, targetState.consumers[consumerId]);
1817
+ consumers[consumerId] = { skills: normalizedConsumerState.skills };
1818
+ if (normalizedConsumerState.migrated) {
1819
+ migrated = true;
1820
+ }
1821
+ }
1822
+ }
1823
+ return {
1824
+ enabledAt: targetState && typeof targetState.enabledAt === "string" ? targetState.enabledAt : "",
1825
+ consumers,
1826
+ migrated,
1827
+ };
1828
+ }
1829
+
1830
+ function normalizeSpecTargets(rawTargets) {
1831
+ const normalizedTargets = {};
1832
+ let migrated = false;
1833
+
1834
+ for (const targetName of SUPPORTED_TARGETS) {
1835
+ if (rawTargets && rawTargets[targetName] && typeof rawTargets[targetName] === "object") {
1836
+ normalizedTargets[targetName] = rawTargets[targetName];
1837
+ }
1838
+ }
1839
+
1840
+ const geminiState = normalizedTargets.gemini;
1841
+ if (
1842
+ geminiState
1843
+ && geminiState.consumers
1844
+ && geminiState.consumers.antigravity
1845
+ && !normalizedTargets.antigravity
1846
+ ) {
1847
+ normalizedTargets.antigravity = {
1848
+ enabledAt: typeof geminiState.enabledAt === "string" ? geminiState.enabledAt : "",
1849
+ consumers: {
1850
+ antigravity: geminiState.consumers.antigravity,
1851
+ },
1852
+ };
1853
+ delete geminiState.consumers.antigravity;
1854
+ migrated = true;
1855
+ }
1856
+
1857
+ const finalTargets = {};
1858
+ for (const targetName of Object.keys(normalizedTargets)) {
1859
+ const normalizedTargetState = normalizeSpecTargetState(targetName, normalizedTargets[targetName]);
1860
+ const consumerIds = Object.keys(normalizedTargetState.consumers);
1861
+ const rawConsumerIds = Object.keys((normalizedTargets[targetName] && normalizedTargets[targetName].consumers) || {});
1862
+ if (consumerIds.length !== rawConsumerIds.length || rawConsumerIds.some((consumerId) => !consumerIds.includes(consumerId))) {
1863
+ migrated = true;
1864
+ }
1865
+ if (normalizedTargetState.migrated) {
1866
+ migrated = true;
1867
+ }
1868
+ finalTargets[targetName] = normalizedTargetState;
1869
+ delete finalTargets[targetName].migrated;
1870
+ }
1871
+
1872
+ return { targets: finalTargets, migrated };
1873
+ }
1874
+
1629
1875
  function readSpecState() {
1630
1876
  const statePath = getSpecStatePath();
1631
1877
  if (!fs.existsSync(statePath)) {
1632
- return { statePath, state: createEmptySpecState() };
1878
+ return { statePath, state: createEmptySpecState(), migrated: false };
1633
1879
  }
1634
1880
 
1635
1881
  const raw = fs.readFileSync(statePath, "utf8").trim();
1636
1882
  if (!raw) {
1637
- return { statePath, state: createEmptySpecState() };
1883
+ return { statePath, state: createEmptySpecState(), migrated: false };
1638
1884
  }
1639
1885
 
1640
1886
  let parsed;
@@ -1646,9 +1892,10 @@ function readSpecState() {
1646
1892
 
1647
1893
  const state = createEmptySpecState();
1648
1894
  state.updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : "";
1649
- state.targets = parsed && typeof parsed.targets === "object" && parsed.targets ? parsed.targets : {};
1895
+ const normalizedTargets = normalizeSpecTargets(parsed && typeof parsed.targets === "object" && parsed.targets ? parsed.targets : {});
1896
+ state.targets = normalizedTargets.targets;
1650
1897
  state.assets = parsed && typeof parsed.assets === "object" && parsed.assets ? parsed.assets : {};
1651
- return { statePath, state };
1898
+ return { statePath, state, migrated: normalizedTargets.migrated };
1652
1899
  }
1653
1900
 
1654
1901
  function writeSpecState(statePath, state) {
@@ -2502,6 +2749,10 @@ async function commandSpecEnable(options) {
2502
2749
  writeSpecState(statePath, state);
2503
2750
  }
2504
2751
 
2752
+ if (targets.includes("codex")) {
2753
+ migrateLegacyCodexGlobalSkills(timestamp, options);
2754
+ }
2755
+
2505
2756
  log(options, `[ok] Spec Profile 已启用 (Targets: ${targets.join(", ")})`);
2506
2757
  }
2507
2758
 
package/docs/PLAN.md CHANGED
@@ -20,10 +20,11 @@
20
20
  - 命令:`ling global sync` / `ling global status`
21
21
  - 默认行为:`ling global sync` 未指定 `--target/--targets` 时,同步 `codex + gemini + antigravity`
22
22
  - 目标路径:
23
- - `codex` -> `$HOME/.codex/skills/`
24
- - `gemini` -> `$HOME/.gemini/skills/`
23
+ - `codex` -> `$HOME/.agents/skills/`
24
+ - `gemini` -> `$HOME/.gemini/skills/`(若与 `$HOME/.agents/skills/` 重叠则清理重复副本)
25
25
  - `antigravity` -> `$HOME/.gemini/antigravity/skills/`
26
26
  - 安全边界:全局只同步 Skills,不写入全局 Rules/Agents/Workflows。
27
+ - 旧版迁移:若存在遗留 `~/.codex/skills/`,同步 codex 时会迁移到 `$HOME/.agents/skills/` 并清理遗留根目录,避免 Skills 重复(冲突内容会备份到 `$HOME/.ling/backups/global/<timestamp>/codex-legacy/...`)。
27
28
 
28
29
  ## 覆盖与回滚(全局同步)
29
30
  - 覆盖单位:每个 Skill 目录。
package/docs/TECH.md CHANGED
@@ -27,8 +27,8 @@ cd web && npm install && npm run lint
27
27
  - Codex:`<project>/.agents-backup/<timestamp>/preflight/.agents/` 或 `<project>/.agents-backup/<timestamp>/preflight/.codex/`
28
28
 
29
29
  ### 全局级(仅同步 Skills)
30
- - `codex`:`$HOME/.codex/skills/`
31
- - `gemini-cli`:`$HOME/.gemini/skills/`
30
+ - `codex`:`$HOME/.agents/skills/`
31
+ - `gemini-cli`:`$HOME/.gemini/skills/`(若存在与 `$HOME/.agents/skills/` 重叠的同名 Skill,灵轨会在同步后清理重复副本)
32
32
  - `antigravity`:`$HOME/.gemini/antigravity/skills/`
33
33
 
34
34
  > 说明:仓库内 Skills 源路径为 `.agents/skills/`,全局同步会将其投影到真实工具读取的全局目录;仓库 Canonical 仍是 `.agents/`。
@@ -64,6 +64,8 @@ cd web && npm install && npm run lint
64
64
  - 原子替换:按 Skill 目录原子替换,避免半写状态
65
65
  - 覆盖前备份:覆盖同名 Skill 前备份到 `$HOME/.ling/backups/global/<timestamp>/<consumer>/<skill>/...`
66
66
  - `consumer` 可能是 `codex`、`gemini-cli`、`antigravity`
67
+ - 遗留迁移:若检测到旧版 `~/.codex/skills/`,同步 codex 时会迁移到 `~/.agents/skills/` 并清理,避免 Skills 重复(冲突内容会备份到 `.../codex-legacy/<skill>/...`)
68
+ - Gemini 去重:若 `~/.agents/skills/` 已存在,同步 gemini 后会移除 `~/.gemini/skills/` 内同名重复目录,并备份到 `.../gemini-cli-redundant/<skill>/...`
67
69
 
68
70
  ### 测试隔离
69
71
  - `LING_GLOBAL_ROOT`:替代 `$HOME`(用于测试与 CI,避免污染真实用户目录)
@@ -154,7 +156,7 @@ cd web && npm install && npm run lint
154
156
  ## 手动回滚(全局 Skills)
155
157
  1. 找到备份目录:`$HOME/.ling/backups/global/<timestamp>/...`
156
158
  2. 按 Skill 回滚(推荐一次只处理一个 Skill 目录):
157
- - Codex 目标:恢复到 `$HOME/.codex/skills/<skill>/`
159
+ - Codex 目标:恢复到 `$HOME/.agents/skills/<skill>/`
158
160
  - Gemini CLI:恢复到 `$HOME/.gemini/skills/<skill>/`
159
161
  - Antigravity:恢复到 `$HOME/.gemini/antigravity/skills/<skill>/`
160
162
 
@@ -162,8 +164,8 @@ macOS / Linux 示例(把某个 Skill 回滚为备份版本):
162
164
  ```bash
163
165
  ts="2026-03-12T12-00-00-000Z"
164
166
  skill="clean-code"
165
- rm -rf "$HOME/.codex/skills/$skill"
166
- cp -a "$HOME/.ling/backups/global/$ts/codex/$skill" "$HOME/.codex/skills/$skill"
167
+ rm -rf "$HOME/.agents/skills/$skill"
168
+ cp -a "$HOME/.ling/backups/global/$ts/codex/$skill" "$HOME/.agents/skills/$skill"
167
169
  ```
168
170
 
169
171
  Windows PowerShell 示例:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mison/ling",
3
- "version": "1.2.0",
3
+ "version": "1.2.3",
4
4
  "description": "面向 Gemini CLI、Antigravity 与 Codex 的中文 AI Agent 模板工具包",
5
5
  "repository": {
6
6
  "type": "git",
@@ -96,13 +96,14 @@ function main() {
96
96
  throw new Error(`spec disable 后状态异常: ${specStatusAfterDisable}`);
97
97
  }
98
98
 
99
- const codexSkill = path.join(globalRoot, ".codex", "skills", "workflow-plan", "SKILL.md");
100
- const geminiCliSkill = path.join(globalRoot, ".gemini", "skills", "clean-code", "SKILL.md");
99
+ const codexSkill = path.join(globalRoot, ".agents", "skills", "workflow-plan", "SKILL.md");
101
100
  const antigravitySkill = path.join(globalRoot, ".gemini", "antigravity", "skills", "clean-code", "SKILL.md");
102
- const specCodexSkill = path.join(globalRoot, ".codex", "skills", "harness-engineering");
101
+ const specCodexSkill = path.join(globalRoot, ".agents", "skills", "harness-engineering");
103
102
  ensureExists(codexSkill, "全局 Codex workflow-plan Skill");
104
- ensureExists(geminiCliSkill, "全局 Gemini CLI clean-code Skill");
105
103
  ensureExists(antigravitySkill, "全局 Antigravity clean-code Skill");
104
+ if (fs.existsSync(path.join(globalRoot, ".gemini", "skills"))) {
105
+ throw new Error("默认全局同步后不应保留重复的 Gemini CLI skills 根目录");
106
+ }
106
107
  if (fs.existsSync(specCodexSkill)) {
107
108
  throw new Error(`spec disable 后仍残留 Spec Skill: ${specCodexSkill}`);
108
109
  }
@@ -113,8 +113,7 @@ function main() {
113
113
  }
114
114
 
115
115
  const globalChecks = [
116
- path.join(globalRoot, ".codex", "skills", "workflow-plan", "SKILL.md"),
117
- path.join(globalRoot, ".gemini", "skills", "clean-code", "SKILL.md"),
116
+ path.join(globalRoot, ".agents", "skills", "workflow-plan", "SKILL.md"),
118
117
  path.join(globalRoot, ".gemini", "antigravity", "skills", "clean-code", "SKILL.md"),
119
118
  ];
120
119
  for (const targetPath of globalChecks) {
@@ -122,6 +121,9 @@ function main() {
122
121
  throw new Error(`global sync 未生成预期文件: ${targetPath}`);
123
122
  }
124
123
  }
124
+ if (fs.existsSync(path.join(globalRoot, ".gemini", "skills"))) {
125
+ throw new Error("默认全局同步后不应保留重复的 Gemini CLI skills 根目录");
126
+ }
125
127
  } finally {
126
128
  fs.rmSync(tempRoot, { recursive: true, force: true });
127
129
  }
@@ -42,7 +42,7 @@ describe("Global Sync", () => {
42
42
  });
43
43
 
44
44
  test("global status should report broken when target root exists but skills are incomplete", () => {
45
- fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
45
+ fs.mkdirSync(path.join(tempDir, ".agents"), { recursive: true });
46
46
 
47
47
  const result = runCli(["global", "status", "--quiet"], {
48
48
  env: { LING_GLOBAL_ROOT: tempDir },
@@ -51,30 +51,92 @@ describe("Global Sync", () => {
51
51
  assert.strictEqual((result.stdout || "").trim(), "broken");
52
52
  });
53
53
 
54
- test("global sync should install codex skills into $HOME/.codex/skills", () => {
54
+ test("global status should report broken when only legacy ~/.codex/skills exists", () => {
55
+ const legacySkillDir = path.join(tempDir, ".codex", "skills", "legacy-skill");
56
+ fs.mkdirSync(legacySkillDir, { recursive: true });
57
+ fs.writeFileSync(path.join(legacySkillDir, "SKILL.md"), "legacy", "utf8");
58
+
59
+ const result = runCli(["global", "status", "--quiet"], {
60
+ env: { LING_GLOBAL_ROOT: tempDir },
61
+ });
62
+ assert.strictEqual(result.status, 1);
63
+ assert.strictEqual((result.stdout || "").trim(), "broken");
64
+ });
65
+
66
+ test("global sync should install codex skills into $HOME/.agents/skills", () => {
55
67
  const result = runCli(["global", "sync", "--target", "codex", "--quiet"], {
56
68
  env: { LING_GLOBAL_ROOT: tempDir },
57
69
  });
58
70
  assert.strictEqual(result.status, 0, result.stderr || result.stdout);
59
71
 
60
- const skillsRoot = path.join(tempDir, ".codex", "skills");
72
+ const skillsRoot = path.join(tempDir, ".agents", "skills");
61
73
  assert.ok(fs.existsSync(skillsRoot), "missing global codex skills root");
62
74
  assert.ok(fs.existsSync(path.join(skillsRoot, "clean-code", "SKILL.md")), "missing expected skill: clean-code");
63
75
  assert.ok(fs.existsSync(path.join(skillsRoot, "workflow-plan", "SKILL.md")), "missing expected workflow skill: workflow-plan");
64
76
  });
65
77
 
78
+ test("global sync should migrate legacy ~/.codex/skills and remove legacy root", () => {
79
+ const legacySkillDir = path.join(tempDir, ".codex", "skills", "legacy-skill");
80
+ fs.mkdirSync(legacySkillDir, { recursive: true });
81
+ fs.writeFileSync(path.join(legacySkillDir, "SKILL.md"), "legacy", "utf8");
82
+
83
+ const result = runCli(["global", "sync", "--target", "codex", "--quiet"], {
84
+ env: { LING_GLOBAL_ROOT: tempDir },
85
+ });
86
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
87
+
88
+ const migratedSkill = path.join(tempDir, ".agents", "skills", "legacy-skill", "SKILL.md");
89
+ assert.ok(fs.existsSync(migratedSkill), "legacy skill should be migrated into .agents/skills");
90
+ assert.strictEqual(fs.readFileSync(migratedSkill, "utf8"), "legacy");
91
+
92
+ assert.ok(!fs.existsSync(path.join(tempDir, ".codex", "skills")), "legacy .codex/skills should be removed");
93
+ });
94
+
95
+ test("global sync should backup and remove conflicting legacy ~/.codex/skills entries", () => {
96
+ const destSkillDir = path.join(tempDir, ".agents", "skills", "conflict-skill");
97
+ fs.mkdirSync(destSkillDir, { recursive: true });
98
+ fs.writeFileSync(path.join(destSkillDir, "SKILL.md"), "agents", "utf8");
99
+
100
+ const legacySkillDir = path.join(tempDir, ".codex", "skills", "conflict-skill");
101
+ fs.mkdirSync(legacySkillDir, { recursive: true });
102
+ fs.writeFileSync(path.join(legacySkillDir, "SKILL.md"), "legacy", "utf8");
103
+
104
+ const result = runCli(["global", "sync", "--target", "codex", "--quiet"], {
105
+ env: { LING_GLOBAL_ROOT: tempDir },
106
+ });
107
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
108
+
109
+ const destSkillMd = path.join(destSkillDir, "SKILL.md");
110
+ assert.strictEqual(fs.readFileSync(destSkillMd, "utf8"), "agents");
111
+
112
+ assert.ok(!fs.existsSync(path.join(tempDir, ".codex", "skills")), "legacy .codex/skills should be removed");
113
+
114
+ const backupRoot = path.join(tempDir, ".ling", "backups", "global");
115
+ assert.ok(fs.existsSync(backupRoot), "missing backup root");
116
+
117
+ const timestamps = fs
118
+ .readdirSync(backupRoot, { withFileTypes: true })
119
+ .filter((entry) => entry.isDirectory())
120
+ .map((entry) => entry.name);
121
+ assert.ok(timestamps.length > 0, "backup timestamp directory missing");
122
+
123
+ const backupSkillMd = path.join(backupRoot, timestamps[0], "codex-legacy", "conflict-skill", "SKILL.md");
124
+ assert.ok(fs.existsSync(backupSkillMd), "backup for legacy conflict skill missing");
125
+ assert.strictEqual(fs.readFileSync(backupSkillMd, "utf8"), "legacy");
126
+ });
127
+
66
128
  test("global sync should default to syncing codex, gemini, and antigravity when no target is provided", () => {
67
129
  const result = runCli(["global", "sync", "--quiet"], {
68
130
  env: { LING_GLOBAL_ROOT: tempDir },
69
131
  });
70
132
  assert.strictEqual(result.status, 0, result.stderr || result.stdout);
71
133
 
72
- const codexRoot = path.join(tempDir, ".codex", "skills");
134
+ const codexRoot = path.join(tempDir, ".agents", "skills");
73
135
  assert.ok(fs.existsSync(path.join(codexRoot, "clean-code", "SKILL.md")), "missing expected codex skill: clean-code");
74
136
  assert.ok(fs.existsSync(path.join(codexRoot, "workflow-plan", "SKILL.md")), "missing expected codex workflow skill: workflow-plan");
75
137
 
76
138
  const geminiCliRoot = path.join(tempDir, ".gemini", "skills");
77
- assert.ok(fs.existsSync(path.join(geminiCliRoot, "clean-code", "SKILL.md")), "missing expected gemini-cli skill: clean-code");
139
+ assert.ok(!fs.existsSync(geminiCliRoot), "default sync should prune redundant gemini-cli skills when universal root exists");
78
140
 
79
141
  const antigravityRoot = path.join(tempDir, ".gemini", "antigravity", "skills");
80
142
  assert.ok(fs.existsSync(path.join(antigravityRoot, "clean-code", "SKILL.md")), "missing expected antigravity skill: clean-code");
@@ -107,6 +169,31 @@ describe("Global Sync", () => {
107
169
  assert.ok(!fs.existsSync(antigravityRoot), "gemini sync should not create antigravity skills root");
108
170
  });
109
171
 
172
+ test("global sync gemini should prune redundant ~/.gemini/skills when universal root already exists", () => {
173
+ const codexResult = runCli(["global", "sync", "--target", "codex", "--quiet"], {
174
+ env: { LING_GLOBAL_ROOT: tempDir },
175
+ });
176
+ assert.strictEqual(codexResult.status, 0, codexResult.stderr || codexResult.stdout);
177
+
178
+ const geminiResult = runCli(["global", "sync", "--target", "gemini", "--quiet"], {
179
+ env: { LING_GLOBAL_ROOT: tempDir },
180
+ });
181
+ assert.strictEqual(geminiResult.status, 0, geminiResult.stderr || geminiResult.stdout);
182
+
183
+ const geminiCliRoot = path.join(tempDir, ".gemini", "skills");
184
+ assert.ok(!fs.existsSync(geminiCliRoot), "redundant gemini-cli skills root should be pruned");
185
+
186
+ const backupRoot = path.join(tempDir, ".ling", "backups", "global");
187
+ const timestamps = fs
188
+ .readdirSync(backupRoot, { withFileTypes: true })
189
+ .filter((entry) => entry.isDirectory())
190
+ .map((entry) => entry.name);
191
+ assert.ok(timestamps.length > 0, "backup timestamp directory missing");
192
+
193
+ const redundantBackup = path.join(backupRoot, timestamps[timestamps.length - 1], "gemini-cli-redundant", "clean-code", "SKILL.md");
194
+ assert.ok(fs.existsSync(redundantBackup), "backup for redundant gemini-cli skill missing");
195
+ });
196
+
110
197
  test("global sync should install antigravity skills only into ~/.gemini/antigravity/skills", () => {
111
198
  const result = runCli(["global", "sync", "--target", "antigravity", "--quiet"], {
112
199
  env: { LING_GLOBAL_ROOT: tempDir },
@@ -122,7 +209,7 @@ describe("Global Sync", () => {
122
209
  });
123
210
 
124
211
  test("global sync should create backup when overwriting an existing skill", () => {
125
- const skillsRoot = path.join(tempDir, ".codex", "skills", "clean-code");
212
+ const skillsRoot = path.join(tempDir, ".agents", "skills", "clean-code");
126
213
  fs.mkdirSync(skillsRoot, { recursive: true });
127
214
  fs.writeFileSync(path.join(skillsRoot, "SKILL.md"), "modified", "utf8");
128
215
 
@@ -47,7 +47,7 @@ describe("Spec Profile", () => {
47
47
  const enableResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env });
48
48
  assert.strictEqual(enableResult.status, 0, enableResult.stderr || enableResult.stdout);
49
49
 
50
- const codexSkill = path.join(tempRoot, ".codex", "skills", "harness-engineering", "SKILL.md");
50
+ const codexSkill = path.join(tempRoot, ".agents", "skills", "harness-engineering", "SKILL.md");
51
51
  const stateFile = path.join(tempRoot, ".ling", "spec", "state.json");
52
52
  const templatesDir = path.join(tempRoot, ".ling", "spec", "templates");
53
53
  const referencesDir = path.join(tempRoot, ".ling", "spec", "references");
@@ -66,7 +66,7 @@ describe("Spec Profile", () => {
66
66
 
67
67
  const disableResult = runCli(["spec", "disable", "--target", "codex", "--quiet"], { env });
68
68
  assert.strictEqual(disableResult.status, 0, disableResult.stderr || disableResult.stdout);
69
- assert.ok(!fs.existsSync(path.join(tempRoot, ".codex", "skills", "harness-engineering")), "spec skill should be removed");
69
+ assert.ok(!fs.existsSync(path.join(tempRoot, ".agents", "skills", "harness-engineering")), "spec skill should be removed");
70
70
  assert.ok(!fs.existsSync(stateFile), "spec state should be removed after final disable");
71
71
  assert.ok(!fs.existsSync(templatesDir), "spec templates should be removed after final disable");
72
72
  assert.ok(!fs.existsSync(referencesDir), "spec references should be removed after final disable");
@@ -75,7 +75,7 @@ describe("Spec Profile", () => {
75
75
 
76
76
  test("spec disable should restore pre-existing skill backup", () => {
77
77
  const env = { LING_GLOBAL_ROOT: tempRoot };
78
- const skillDir = path.join(tempRoot, ".codex", "skills", "harness-engineering");
78
+ const skillDir = path.join(tempRoot, ".agents", "skills", "harness-engineering");
79
79
  fs.mkdirSync(skillDir, { recursive: true });
80
80
  fs.writeFileSync(path.join(skillDir, "SKILL.md"), "legacy skill", "utf8");
81
81
 
@@ -95,7 +95,7 @@ describe("Spec Profile", () => {
95
95
  assert.strictEqual(enableResult.status, 0, enableResult.stderr || enableResult.stdout);
96
96
 
97
97
  const templatesDir = path.join(tempRoot, ".ling", "spec", "templates");
98
- const codexSkillDir = path.join(tempRoot, ".codex", "skills", "harness-engineering");
98
+ const codexSkillDir = path.join(tempRoot, ".agents", "skills", "harness-engineering");
99
99
  fs.rmSync(templatesDir, { recursive: true, force: true });
100
100
  fs.rmSync(codexSkillDir, { recursive: true, force: true });
101
101
 
@@ -130,6 +130,53 @@ describe("Spec Profile", () => {
130
130
  assert.strictEqual((statusResult.stdout || "").trim(), "installed");
131
131
  });
132
132
 
133
+ test("spec enable should install gemini skills independently", () => {
134
+ const env = { LING_GLOBAL_ROOT: tempRoot };
135
+
136
+ const enableResult = runCli(["spec", "enable", "--target", "gemini", "--quiet"], { env });
137
+ assert.strictEqual(enableResult.status, 0, enableResult.stderr || enableResult.stdout);
138
+
139
+ const geminiSkill = path.join(tempRoot, ".gemini", "skills", "harness-engineering", "SKILL.md");
140
+ const antigravitySkill = path.join(tempRoot, ".gemini", "antigravity", "skills", "harness-engineering", "SKILL.md");
141
+ assert.ok(fs.existsSync(geminiSkill), "missing installed gemini spec skill");
142
+ assert.ok(!fs.existsSync(antigravitySkill), "gemini spec enable should not install antigravity skill");
143
+ });
144
+
145
+ test("spec enable should migrate legacy codex global skill path to ~/.agents/skills", () => {
146
+ const env = { LING_GLOBAL_ROOT: tempRoot };
147
+ const stateDir = path.join(tempRoot, ".ling", "spec");
148
+ fs.mkdirSync(stateDir, { recursive: true });
149
+ fs.writeFileSync(path.join(stateDir, "state.json"), JSON.stringify({
150
+ version: 1,
151
+ updatedAt: new Date().toISOString(),
152
+ targets: {
153
+ codex: {
154
+ enabledAt: new Date().toISOString(),
155
+ consumers: {
156
+ codex: {
157
+ skills: [
158
+ {
159
+ name: "harness-engineering",
160
+ destPath: path.join(tempRoot, ".codex", "skills", "harness-engineering"),
161
+ backupPath: "",
162
+ mode: "created",
163
+ },
164
+ ],
165
+ },
166
+ },
167
+ },
168
+ },
169
+ assets: {},
170
+ }, null, 2), "utf8");
171
+
172
+ const enableResult = runCli(["spec", "enable", "--target", "codex", "--quiet"], { env });
173
+ assert.strictEqual(enableResult.status, 0, enableResult.stderr || enableResult.stdout);
174
+
175
+ const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
176
+ const skillState = state.targets.codex.consumers.codex.skills.find((skill) => skill.name === "harness-engineering");
177
+ assert.ok(skillState.destPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}harness-engineering`), "legacy codex path should be migrated to .agents/skills");
178
+ });
179
+
133
180
  test("spec status should report broken when an asset file is missing", () => {
134
181
  const env = { LING_GLOBAL_ROOT: tempRoot };
135
182
 
@@ -137,11 +137,11 @@ describe('Standards Compliance', () => {
137
137
  const file = path.resolve('docs/TECH.md');
138
138
  const content = fs.readFileSync(file, 'utf8');
139
139
 
140
- assert.ok(content.includes('$HOME/.codex/skills/'), 'missing global skill path: $HOME/.codex/skills/');
140
+ assert.ok(content.includes('$HOME/.agents/skills/'), 'missing global skill path: $HOME/.agents/skills/');
141
141
  assert.ok(content.includes('$HOME/.gemini/skills/'), 'missing global skill path: $HOME/.gemini/skills/');
142
142
  assert.ok(content.includes('$HOME/.gemini/antigravity/skills/'), 'missing global skill path: $HOME/.gemini/antigravity/skills/');
143
143
  assert.ok(content.includes('.agents/skills'), 'missing repo skill path: .agents/skills');
144
- assert.ok(!content.includes('$HOME/.agents/skills/'), 'should not contain deprecated global path: $HOME/.agents/skills/');
144
+ assert.ok(!content.includes('$HOME/.codex/skills/'), 'should not contain deprecated global path: $HOME/.codex/skills/');
145
145
  });
146
146
 
147
147
  test('README spec section should reflect implemented spec commands', () => {