@mison/ling 1.2.0 → 1.2.2

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,20 @@
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [ling-1.2.2] - 2026-03-14
11
+
12
+ ### 修复
13
+
14
+ - 修复全局 Skills 重复:当检测到旧版 `~/.codex/skills/` 时,同步 `codex` 会迁移到 `~/.agents/skills/` 并清理遗留目录;若与现有目录冲突则备份到 `~/.ling/backups/global/<timestamp>/codex-legacy/...`。
15
+
16
+ ## [ling-1.2.1] - 2026-03-14
17
+
18
+ ### 修复
19
+
20
+ - 修正 Codex 全局 Skill 安装根目录:`ling global sync --target codex` 与 `ling spec enable --target codex` 现在统一写入官方要求的 `~/.agents/skills/`,不再误写到旧的 `~/.codex/skills/`。
21
+ - 兼容旧版 Spec 状态:历史 `state.json` 中记录的 `~/.codex/skills/...` 路径会在读取时自动迁移为 `~/.agents/skills/...`,避免修复后仍引用旧路径。
22
+ - 增加旧路径诊断:若用户机器上只存在旧版 `~/.codex/skills/`,`ling global status` 会返回 `broken` 并提示执行 `ling global sync --target codex` 修复。
23
+
10
24
  ## [ling-1.2.0] - 2026-03-14
11
25
 
12
26
  ### 新增
@@ -98,7 +112,8 @@
98
112
 
99
113
  本项目在 Ling 重启前的 2.x/3.x 版本记录已冻结,不再维护。
100
114
 
101
- [Unreleased]: https://github.com/MisonL/Ling/compare/ling-1.2.0...HEAD
115
+ [Unreleased]: https://github.com/MisonL/Ling/compare/ling-1.2.1...HEAD
116
+ [ling-1.2.1]: https://github.com/MisonL/Ling/releases/tag/ling-1.2.1
102
117
  [ling-1.2.0]: https://github.com/MisonL/Ling/releases/tag/ling-1.2.0
103
118
  [ling-1.1.1]: https://github.com/MisonL/Ling/releases/tag/ling-1.1.1
104
119
  [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/skills/` 等 |
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/`
92
+ - `codex` -> `~/.agents/skills/`
93
93
  - `gemini` -> `~/.gemini/skills/`
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,7 @@ 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";
64
65
 
65
66
  function nowISO() {
66
67
  return new Date().toISOString();
@@ -1154,6 +1155,22 @@ function evaluateGlobalState() {
1154
1155
  };
1155
1156
  }).filter((item) => item.state !== "missing");
1156
1157
 
1158
+ const legacyCodexSkillsRoot = path.join(globalRoot, ".codex", "skills");
1159
+ const hasCurrentCodexRoot = targetStates.some((item) => item.targetName === "codex");
1160
+ if (!hasCurrentCodexRoot && fs.existsSync(legacyCodexSkillsRoot)) {
1161
+ targetStates.push({
1162
+ targetName: "codex",
1163
+ family: "codex",
1164
+ state: "broken",
1165
+ rootDir: path.join(globalRoot, ".codex"),
1166
+ skillsRoot: legacyCodexSkillsRoot,
1167
+ skillsCount: countSkillsRecursive(legacyCodexSkillsRoot),
1168
+ issues: [
1169
+ "检测到旧版 Codex 全局 Skill 目录;当前版本改用 ~/.agents/skills(运行: ling global sync --target codex)",
1170
+ ],
1171
+ });
1172
+ }
1173
+
1157
1174
  if (targetStates.length === 0) {
1158
1175
  return {
1159
1176
  globalRoot,
@@ -1271,6 +1288,74 @@ function backupSkillDirectory(targetName, skillName, sourceDir, timestamp, optio
1271
1288
  log(options, `[backup] 已备份 ${targetName} 全局 Skill: ${skillName} -> ${backupDir}`);
1272
1289
  }
1273
1290
 
1291
+ function migrateLegacyCodexGlobalSkills(timestamp, options) {
1292
+ const globalRoot = resolveGlobalRootDir();
1293
+ const legacyRoot = path.join(globalRoot, ".codex", "skills");
1294
+ if (!fs.existsSync(legacyRoot)) {
1295
+ return { migrated: 0, removed: 0, backedUp: 0, conflicts: 0, remaining: 0 };
1296
+ }
1297
+
1298
+ const destRoot = path.join(globalRoot, ".agents", "skills");
1299
+ const legacyDirEntries = fs.readdirSync(legacyRoot, { withFileTypes: true });
1300
+ const legacyEntries = legacyDirEntries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1301
+ if (legacyEntries.length === 0) {
1302
+ if (legacyDirEntries.length === 0) {
1303
+ removeDirIfExists(legacyRoot, options, "遗留 Codex skills 根目录");
1304
+ } else {
1305
+ log(options, `[warn] 遗留 Codex skills 根目录包含非目录条目,已跳过迁移清理: ${legacyRoot}`);
1306
+ }
1307
+ return { migrated: 0, removed: 0, backedUp: 0, conflicts: 0, remaining: legacyDirEntries.length };
1308
+ }
1309
+
1310
+ let migrated = 0;
1311
+ let removed = 0;
1312
+ let backedUp = 0;
1313
+ let conflicts = 0;
1314
+
1315
+ for (const entryName of legacyEntries) {
1316
+ const legacyDir = path.join(legacyRoot, entryName);
1317
+ const destDir = path.join(destRoot, entryName);
1318
+ const destExists = fs.existsSync(destDir);
1319
+
1320
+ if (!destExists) {
1321
+ if (options.dryRun) {
1322
+ log(options, `[dry-run] 将迁移遗留 Codex Skill 目录: ${legacyDir} -> ${destDir}`);
1323
+ } else {
1324
+ const logger = options.quiet ? (() => {}) : log.bind(null, options);
1325
+ AtomicWriter.atomicCopyDir(legacyDir, destDir, { logger });
1326
+ log(options, `[ok] 已迁移遗留 Codex Skill 目录: ${legacyDir} -> ${destDir}`);
1327
+ }
1328
+ migrated += 1;
1329
+ } else if (!areDirectoriesEqual(legacyDir, destDir)) {
1330
+ conflicts += 1;
1331
+ if (options.dryRun) {
1332
+ log(options, `[dry-run] 将备份并移除遗留 Codex Skill(与现有目录冲突): ${legacyDir}`);
1333
+ } else {
1334
+ backupSkillDirectory(LEGACY_CODEX_GLOBAL_SKILLS_BACKUP_TARGET, entryName, legacyDir, timestamp, options);
1335
+ backedUp += 1;
1336
+ }
1337
+ }
1338
+
1339
+ if (options.dryRun) {
1340
+ log(options, `[dry-run] 将删除遗留 Codex Skill 目录: ${legacyDir}`);
1341
+ continue;
1342
+ }
1343
+
1344
+ fs.rmSync(legacyDir, { recursive: true, force: true });
1345
+ log(options, `[clean] 已删除遗留 Codex Skill 目录: ${legacyDir}`);
1346
+ removed += 1;
1347
+ }
1348
+
1349
+ const remaining = fs.existsSync(legacyRoot) ? fs.readdirSync(legacyRoot).length : 0;
1350
+ if (remaining === 0) {
1351
+ removeDirIfExists(legacyRoot, options, "遗留 Codex skills 根目录");
1352
+ } else {
1353
+ log(options, `[warn] 遗留 Codex skills 根目录仍有残留条目(${remaining}),已保留: ${legacyRoot}`);
1354
+ }
1355
+
1356
+ return { migrated, removed, backedUp, conflicts, remaining };
1357
+ }
1358
+
1274
1359
  function syncSkillDirectory(destination, srcDir, destDir, timestamp, options) {
1275
1360
  const exists = fs.existsSync(destDir);
1276
1361
  if (exists) {
@@ -1537,6 +1622,10 @@ async function commandGlobalSync(options) {
1537
1622
  log(options, ` - ${item.targetName}: ${item.destRoot}(每目标 ${item.total} 个 Skills)`);
1538
1623
  }
1539
1624
  }
1625
+
1626
+ if (target === "codex") {
1627
+ migrateLegacyCodexGlobalSkills(timestamp, options);
1628
+ }
1540
1629
  } finally {
1541
1630
  if (plan.cleanup) plan.cleanup();
1542
1631
  }
@@ -1626,15 +1715,121 @@ function createEmptySpecState() {
1626
1715
  };
1627
1716
  }
1628
1717
 
1718
+ function rewriteLegacyCodexGlobalSkillPath(targetPath) {
1719
+ if (typeof targetPath !== "string" || targetPath === "") {
1720
+ return targetPath;
1721
+ }
1722
+ const normalizedPath = targetPath.replace(/\\/g, "/");
1723
+ if (!normalizedPath.includes("/.codex/skills")) {
1724
+ return targetPath;
1725
+ }
1726
+ return targetPath.replace(/([\\/])\.codex([\\/])skills/g, "$1.agents$2skills");
1727
+ }
1728
+
1729
+ function normalizeSpecSkillState(targetName, consumerId, skillState) {
1730
+ if (!skillState || typeof skillState !== "object" || typeof skillState.name !== "string" || !skillState.name) {
1731
+ return null;
1732
+ }
1733
+ const destPath = typeof skillState.destPath === "string" ? skillState.destPath : "";
1734
+ return {
1735
+ name: skillState.name,
1736
+ destPath: targetName === "codex" && consumerId === "codex"
1737
+ ? rewriteLegacyCodexGlobalSkillPath(destPath)
1738
+ : destPath,
1739
+ backupPath: typeof skillState.backupPath === "string" ? skillState.backupPath : "",
1740
+ mode: normalizeSpecAssetMode(skillState),
1741
+ };
1742
+ }
1743
+
1744
+ function normalizeSpecConsumerState(targetName, consumerId, consumerState) {
1745
+ const skills = [];
1746
+ let migrated = false;
1747
+ for (const skillState of Array.isArray(consumerState && consumerState.skills) ? consumerState.skills : []) {
1748
+ const normalizedSkill = normalizeSpecSkillState(targetName, consumerId, skillState);
1749
+ if (normalizedSkill) {
1750
+ skills.push(normalizedSkill);
1751
+ if (normalizedSkill.destPath !== (typeof skillState.destPath === "string" ? skillState.destPath : "")) {
1752
+ migrated = true;
1753
+ }
1754
+ }
1755
+ }
1756
+ return { skills, migrated };
1757
+ }
1758
+
1759
+ function normalizeSpecTargetState(targetName, targetState) {
1760
+ const allowedConsumers = new Set(getGlobalDestinations(targetName).map((destination) => destination.id));
1761
+ const consumers = {};
1762
+ let migrated = false;
1763
+ for (const consumerId of allowedConsumers) {
1764
+ if (targetState && targetState.consumers && targetState.consumers[consumerId]) {
1765
+ const normalizedConsumerState = normalizeSpecConsumerState(targetName, consumerId, targetState.consumers[consumerId]);
1766
+ consumers[consumerId] = { skills: normalizedConsumerState.skills };
1767
+ if (normalizedConsumerState.migrated) {
1768
+ migrated = true;
1769
+ }
1770
+ }
1771
+ }
1772
+ return {
1773
+ enabledAt: targetState && typeof targetState.enabledAt === "string" ? targetState.enabledAt : "",
1774
+ consumers,
1775
+ migrated,
1776
+ };
1777
+ }
1778
+
1779
+ function normalizeSpecTargets(rawTargets) {
1780
+ const normalizedTargets = {};
1781
+ let migrated = false;
1782
+
1783
+ for (const targetName of SUPPORTED_TARGETS) {
1784
+ if (rawTargets && rawTargets[targetName] && typeof rawTargets[targetName] === "object") {
1785
+ normalizedTargets[targetName] = rawTargets[targetName];
1786
+ }
1787
+ }
1788
+
1789
+ const geminiState = normalizedTargets.gemini;
1790
+ if (
1791
+ geminiState
1792
+ && geminiState.consumers
1793
+ && geminiState.consumers.antigravity
1794
+ && !normalizedTargets.antigravity
1795
+ ) {
1796
+ normalizedTargets.antigravity = {
1797
+ enabledAt: typeof geminiState.enabledAt === "string" ? geminiState.enabledAt : "",
1798
+ consumers: {
1799
+ antigravity: geminiState.consumers.antigravity,
1800
+ },
1801
+ };
1802
+ delete geminiState.consumers.antigravity;
1803
+ migrated = true;
1804
+ }
1805
+
1806
+ const finalTargets = {};
1807
+ for (const targetName of Object.keys(normalizedTargets)) {
1808
+ const normalizedTargetState = normalizeSpecTargetState(targetName, normalizedTargets[targetName]);
1809
+ const consumerIds = Object.keys(normalizedTargetState.consumers);
1810
+ const rawConsumerIds = Object.keys((normalizedTargets[targetName] && normalizedTargets[targetName].consumers) || {});
1811
+ if (consumerIds.length !== rawConsumerIds.length || rawConsumerIds.some((consumerId) => !consumerIds.includes(consumerId))) {
1812
+ migrated = true;
1813
+ }
1814
+ if (normalizedTargetState.migrated) {
1815
+ migrated = true;
1816
+ }
1817
+ finalTargets[targetName] = normalizedTargetState;
1818
+ delete finalTargets[targetName].migrated;
1819
+ }
1820
+
1821
+ return { targets: finalTargets, migrated };
1822
+ }
1823
+
1629
1824
  function readSpecState() {
1630
1825
  const statePath = getSpecStatePath();
1631
1826
  if (!fs.existsSync(statePath)) {
1632
- return { statePath, state: createEmptySpecState() };
1827
+ return { statePath, state: createEmptySpecState(), migrated: false };
1633
1828
  }
1634
1829
 
1635
1830
  const raw = fs.readFileSync(statePath, "utf8").trim();
1636
1831
  if (!raw) {
1637
- return { statePath, state: createEmptySpecState() };
1832
+ return { statePath, state: createEmptySpecState(), migrated: false };
1638
1833
  }
1639
1834
 
1640
1835
  let parsed;
@@ -1646,9 +1841,10 @@ function readSpecState() {
1646
1841
 
1647
1842
  const state = createEmptySpecState();
1648
1843
  state.updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : "";
1649
- state.targets = parsed && typeof parsed.targets === "object" && parsed.targets ? parsed.targets : {};
1844
+ const normalizedTargets = normalizeSpecTargets(parsed && typeof parsed.targets === "object" && parsed.targets ? parsed.targets : {});
1845
+ state.targets = normalizedTargets.targets;
1650
1846
  state.assets = parsed && typeof parsed.assets === "object" && parsed.assets ? parsed.assets : {};
1651
- return { statePath, state };
1847
+ return { statePath, state, migrated: normalizedTargets.migrated };
1652
1848
  }
1653
1849
 
1654
1850
  function writeSpecState(statePath, state) {
@@ -2502,6 +2698,10 @@ async function commandSpecEnable(options) {
2502
2698
  writeSpecState(statePath, state);
2503
2699
  }
2504
2700
 
2701
+ if (targets.includes("codex")) {
2702
+ migrateLegacyCodexGlobalSkills(timestamp, options);
2703
+ }
2704
+
2505
2705
  log(options, `[ok] Spec Profile 已启用 (Targets: ${targets.join(", ")})`);
2506
2706
  }
2507
2707
 
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/`
23
+ - `codex` -> `$HOME/.agents/skills/`
24
24
  - `gemini` -> `$HOME/.gemini/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,7 +27,7 @@ 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/`
30
+ - `codex`:`$HOME/.agents/skills/`
31
31
  - `gemini-cli`:`$HOME/.gemini/skills/`
32
32
  - `antigravity`:`$HOME/.gemini/antigravity/skills/`
33
33
 
@@ -64,6 +64,7 @@ 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>/...`)
67
68
 
68
69
  ### 测试隔离
69
70
  - `LING_GLOBAL_ROOT`:替代 `$HOME`(用于测试与 CI,避免污染真实用户目录)
@@ -154,7 +155,7 @@ cd web && npm install && npm run lint
154
155
  ## 手动回滚(全局 Skills)
155
156
  1. 找到备份目录:`$HOME/.ling/backups/global/<timestamp>/...`
156
157
  2. 按 Skill 回滚(推荐一次只处理一个 Skill 目录):
157
- - Codex 目标:恢复到 `$HOME/.codex/skills/<skill>/`
158
+ - Codex 目标:恢复到 `$HOME/.agents/skills/<skill>/`
158
159
  - Gemini CLI:恢复到 `$HOME/.gemini/skills/<skill>/`
159
160
  - Antigravity:恢复到 `$HOME/.gemini/antigravity/skills/<skill>/`
160
161
 
@@ -162,8 +163,8 @@ macOS / Linux 示例(把某个 Skill 回滚为备份版本):
162
163
  ```bash
163
164
  ts="2026-03-12T12-00-00-000Z"
164
165
  skill="clean-code"
165
- rm -rf "$HOME/.codex/skills/$skill"
166
- cp -a "$HOME/.ling/backups/global/$ts/codex/$skill" "$HOME/.codex/skills/$skill"
166
+ rm -rf "$HOME/.agents/skills/$skill"
167
+ cp -a "$HOME/.ling/backups/global/$ts/codex/$skill" "$HOME/.agents/skills/$skill"
167
168
  ```
168
169
 
169
170
  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.2",
4
4
  "description": "面向 Gemini CLI、Antigravity 与 Codex 的中文 AI Agent 模板工具包",
5
5
  "repository": {
6
6
  "type": "git",
@@ -96,10 +96,10 @@ 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");
99
+ const codexSkill = path.join(globalRoot, ".agents", "skills", "workflow-plan", "SKILL.md");
100
100
  const geminiCliSkill = path.join(globalRoot, ".gemini", "skills", "clean-code", "SKILL.md");
101
101
  const antigravitySkill = path.join(globalRoot, ".gemini", "antigravity", "skills", "clean-code", "SKILL.md");
102
- const specCodexSkill = path.join(globalRoot, ".codex", "skills", "harness-engineering");
102
+ const specCodexSkill = path.join(globalRoot, ".agents", "skills", "harness-engineering");
103
103
  ensureExists(codexSkill, "全局 Codex workflow-plan Skill");
104
104
  ensureExists(geminiCliSkill, "全局 Gemini CLI clean-code Skill");
105
105
  ensureExists(antigravitySkill, "全局 Antigravity clean-code Skill");
@@ -113,7 +113,7 @@ function main() {
113
113
  }
114
114
 
115
115
  const globalChecks = [
116
- path.join(globalRoot, ".codex", "skills", "workflow-plan", "SKILL.md"),
116
+ path.join(globalRoot, ".agents", "skills", "workflow-plan", "SKILL.md"),
117
117
  path.join(globalRoot, ".gemini", "skills", "clean-code", "SKILL.md"),
118
118
  path.join(globalRoot, ".gemini", "antigravity", "skills", "clean-code", "SKILL.md"),
119
119
  ];
@@ -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,25 +51,87 @@ 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
 
@@ -122,7 +184,7 @@ describe("Global Sync", () => {
122
184
  });
123
185
 
124
186
  test("global sync should create backup when overwriting an existing skill", () => {
125
- const skillsRoot = path.join(tempDir, ".codex", "skills", "clean-code");
187
+ const skillsRoot = path.join(tempDir, ".agents", "skills", "clean-code");
126
188
  fs.mkdirSync(skillsRoot, { recursive: true });
127
189
  fs.writeFileSync(path.join(skillsRoot, "SKILL.md"), "modified", "utf8");
128
190
 
@@ -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', () => {