@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 +16 -1
- package/README.md +4 -2
- package/bin/ling-cli.js +206 -6
- package/docs/PLAN.md +2 -1
- package/docs/TECH.md +5 -4
- package/package.json +1 -1
- package/scripts/ci-verify.js +2 -2
- package/scripts/health-check.js +1 -1
- package/tests/global-sync.test.js +67 -5
- package/tests/spec-profile.test.js +51 -4
- package/tests/standards-compliance.test.js +2 -2
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.
|
|
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` | 写入 `~/.
|
|
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` -> `~/.
|
|
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: [".
|
|
32
|
-
skillsParts: [".
|
|
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
|
-
|
|
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/.
|
|
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/.
|
|
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/.
|
|
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/.
|
|
166
|
-
cp -a "$HOME/.ling/backups/global/$ts/codex/$skill" "$HOME/.
|
|
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
package/scripts/ci-verify.js
CHANGED
|
@@ -96,10 +96,10 @@ function main() {
|
|
|
96
96
|
throw new Error(`spec disable 后状态异常: ${specStatusAfterDisable}`);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
const codexSkill = path.join(globalRoot, ".
|
|
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, ".
|
|
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");
|
package/scripts/health-check.js
CHANGED
|
@@ -113,7 +113,7 @@ function main() {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
const globalChecks = [
|
|
116
|
-
path.join(globalRoot, ".
|
|
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, ".
|
|
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
|
|
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, ".
|
|
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, ".
|
|
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, ".
|
|
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, ".
|
|
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, ".
|
|
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, ".
|
|
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, ".
|
|
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/.
|
|
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/.
|
|
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', () => {
|