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