@mison/ling 1.0.2 → 1.1.1
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/.agents/rules/GEMINI.md +17 -0
- package/.agents/skills/clean-code/SKILL.md +24 -14
- package/.agents/skills/doc.md +9 -5
- package/CHANGELOG.md +29 -3
- package/README.md +37 -23
- package/bin/adapters/codex.js +6 -6
- package/bin/adapters/gemini.js +1 -1
- package/bin/core/generator.js +1 -0
- package/bin/interactive.js +68 -1
- package/bin/{ag-kit.js → ling-cli.js} +1078 -131
- package/bin/ling.js +1 -1
- package/bin/utils/managed-block.js +17 -4
- package/bin/utils.js +53 -10
- package/docs/TECH.md +37 -13
- package/package.json +6 -3
- package/scripts/ci-verify.js +9 -3
- package/scripts/postinstall-check.js +5 -6
- package/tests/clean-script.test.js +1 -1
- package/tests/cli-smoke.test.js +85 -0
- package/tests/managed-block.test.js +18 -1
- package/tests/phase-c.test.js +2 -2
- package/tests/spec-init-doctor.test.js +175 -0
- package/tests/spec-profile.test.js +68 -0
- package/tests/standards-compliance.test.js +1 -1
- package/tests/transformer.test.js +1 -1
|
@@ -5,19 +5,17 @@ const os = require("os");
|
|
|
5
5
|
const path = require("path");
|
|
6
6
|
|
|
7
7
|
const pkg = require("../package.json");
|
|
8
|
-
const { readGlobalNpmDependencies, cloneBranchAgentDir } = require("./utils");
|
|
8
|
+
const { readGlobalNpmDependencies, cloneBranchAgentDir, cloneBranchSpecDir } = require("./utils");
|
|
9
9
|
const ManifestManager = require("./utils/manifest");
|
|
10
10
|
const AtomicWriter = require("./utils/atomic-writer");
|
|
11
11
|
const CodexBuilder = require("./core/builder");
|
|
12
12
|
const GeminiAdapter = require("./adapters/gemini");
|
|
13
13
|
const CodexAdapter = require("./adapters/codex");
|
|
14
|
-
const { selectTargets } = require("./interactive");
|
|
14
|
+
const { selectTargets, createConflictPrompter } = require("./interactive");
|
|
15
15
|
|
|
16
16
|
const BUNDLED_AGENT_DIR = path.resolve(__dirname, "../.agents");
|
|
17
17
|
const BUNDLED_SPEC_DIR = path.resolve(__dirname, "../.spec");
|
|
18
18
|
const PRIMARY_CLI_NAME = "ling";
|
|
19
|
-
const LEGACY_CLI_NAME = "ag-kit";
|
|
20
|
-
const CURRENT_CLI_BASENAME = path.basename(process.argv[1] || "", path.extname(process.argv[1] || "")) || PRIMARY_CLI_NAME;
|
|
21
19
|
const WORKSPACE_INDEX_VERSION = 2;
|
|
22
20
|
const UPSTREAM_GLOBAL_PACKAGE = "@vudovn/ag-kit";
|
|
23
21
|
const TOOLKIT_PACKAGE_NAMES = new Set(["@mison/ling", "@mison/ag-kit-cn", "antigravity-kit-cn", "antigravity-kit"]);
|
|
@@ -57,6 +55,9 @@ const QUIET_STATUS_EXIT_CODES = {
|
|
|
57
55
|
const SPEC_STATE_VERSION = 1;
|
|
58
56
|
const SPEC_SKILL_NAMES = ["harness-engineering", "cybernetic-systems-engineering"];
|
|
59
57
|
const VERSION_TAG_PREFIX = "ling-";
|
|
58
|
+
const SPEC_TEMPLATE_REQUIRED_FILES = ["issues.template.csv", "driver-prompt.md", "review-report.md", "phase-acceptance.md", "handoff.md"];
|
|
59
|
+
const SPEC_REFERENCE_REQUIRED_FILES = ["README.md", "harness-engineering-digest.md", "gda-framework.md", "cse-quickstart.md"];
|
|
60
|
+
const SPEC_PROFILE_REQUIRED_FILES = ["codex/AGENTS.spec.md", "codex/ling.spec.rules.md", "gemini/GEMINI.spec.md"];
|
|
60
61
|
|
|
61
62
|
function nowISO() {
|
|
62
63
|
return new Date().toISOString();
|
|
@@ -67,36 +68,11 @@ function getVersionTag() {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
function getControlHomeDir() {
|
|
70
|
-
|
|
71
|
-
const legacy = path.join(os.homedir(), ".ag-kit");
|
|
72
|
-
|
|
73
|
-
if (fs.existsSync(preferred)) {
|
|
74
|
-
return preferred;
|
|
75
|
-
}
|
|
76
|
-
if (fs.existsSync(legacy)) {
|
|
77
|
-
return legacy;
|
|
78
|
-
}
|
|
79
|
-
return preferred;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function migrateLegacyControlHomeDir() {
|
|
83
|
-
if (process.env.LING_INDEX_PATH || process.env.AG_KIT_INDEX_PATH) {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const preferred = path.join(os.homedir(), ".ling");
|
|
88
|
-
const legacy = path.join(os.homedir(), ".ag-kit");
|
|
89
|
-
|
|
90
|
-
if (fs.existsSync(preferred) || !fs.existsSync(legacy)) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
copyDirRecursive(legacy, preferred);
|
|
95
|
-
return { from: legacy, to: preferred };
|
|
71
|
+
return path.join(os.homedir(), ".ling");
|
|
96
72
|
}
|
|
97
73
|
|
|
98
74
|
function getWorkspaceIndexPath() {
|
|
99
|
-
const customPath = process.env.LING_INDEX_PATH
|
|
75
|
+
const customPath = process.env.LING_INDEX_PATH;
|
|
100
76
|
if (customPath) {
|
|
101
77
|
return path.resolve(process.cwd(), customPath);
|
|
102
78
|
}
|
|
@@ -113,7 +89,7 @@ function createEmptyWorkspaceIndex() {
|
|
|
113
89
|
}
|
|
114
90
|
|
|
115
91
|
function resolveGlobalRootDir() {
|
|
116
|
-
const customRoot = process.env.LING_GLOBAL_ROOT
|
|
92
|
+
const customRoot = process.env.LING_GLOBAL_ROOT;
|
|
117
93
|
if (typeof customRoot === "string" && customRoot.trim()) {
|
|
118
94
|
return path.resolve(process.cwd(), customRoot);
|
|
119
95
|
}
|
|
@@ -156,6 +132,22 @@ function copyDirRecursive(src, dest) {
|
|
|
156
132
|
}
|
|
157
133
|
}
|
|
158
134
|
|
|
135
|
+
function backupWorkspaceDir(workspaceRoot, sourceDir, backupRootName, timestamp, options, label) {
|
|
136
|
+
if (!fs.existsSync(sourceDir)) {
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
139
|
+
const assetName = path.basename(sourceDir) || "asset";
|
|
140
|
+
const backupDir = path.join(workspaceRoot, backupRootName, timestamp, "preflight", assetName);
|
|
141
|
+
if (options.dryRun) {
|
|
142
|
+
log(options, `[dry-run] 将备份 ${label}: ${sourceDir} -> ${backupDir}`);
|
|
143
|
+
return backupDir;
|
|
144
|
+
}
|
|
145
|
+
fs.mkdirSync(path.dirname(backupDir), { recursive: true });
|
|
146
|
+
copyDirRecursive(sourceDir, backupDir);
|
|
147
|
+
log(options, `[backup] 已备份 ${label}: ${sourceDir} -> ${backupDir}`);
|
|
148
|
+
return backupDir;
|
|
149
|
+
}
|
|
150
|
+
|
|
159
151
|
function areDirectoriesEqual(leftDir, rightDir) {
|
|
160
152
|
const left = ManifestManager.generateFromDir(leftDir);
|
|
161
153
|
const right = ManifestManager.generateFromDir(rightDir);
|
|
@@ -183,6 +175,8 @@ function printUsage() {
|
|
|
183
175
|
console.log(` ${PRIMARY_CLI_NAME} spec enable [--target <name>|--targets <a,b>] [--quiet] [--dry-run]`);
|
|
184
176
|
console.log(` ${PRIMARY_CLI_NAME} spec disable [--target <name>|--targets <a,b>] [--quiet] [--dry-run]`);
|
|
185
177
|
console.log(` ${PRIMARY_CLI_NAME} spec status [--quiet]`);
|
|
178
|
+
console.log(` ${PRIMARY_CLI_NAME} spec init [--path <dir>] [--target <name>|--targets <a,b>] [--branch <name>] [--force] [--non-interactive] [--no-index] [--quiet] [--dry-run]`);
|
|
179
|
+
console.log(` ${PRIMARY_CLI_NAME} spec doctor [--path <dir>] [--quiet]`);
|
|
186
180
|
console.log(` ${PRIMARY_CLI_NAME} exclude list [--quiet]`);
|
|
187
181
|
console.log(` ${PRIMARY_CLI_NAME} exclude add --path <dir> [--dry-run] [--quiet]`);
|
|
188
182
|
console.log(` ${PRIMARY_CLI_NAME} exclude remove --path <dir> [--dry-run] [--quiet]`);
|
|
@@ -301,6 +295,8 @@ const COMMAND_ALLOWED_FLAGS = {
|
|
|
301
295
|
"spec:enable": ["--target", "--targets", "--quiet", "--dry-run"],
|
|
302
296
|
"spec:disable": ["--target", "--targets", "--quiet", "--dry-run"],
|
|
303
297
|
"spec:status": ["--quiet"],
|
|
298
|
+
"spec:init": ["--force", "--path", "--branch", "--target", "--targets", "--non-interactive", "--no-index", "--quiet", "--dry-run"],
|
|
299
|
+
"spec:doctor": ["--path", "--quiet"],
|
|
304
300
|
"exclude:list": ["--quiet"],
|
|
305
301
|
"exclude:add": ["--path", "--dry-run", "--quiet"],
|
|
306
302
|
"exclude:remove": ["--path", "--dry-run", "--quiet"],
|
|
@@ -428,10 +424,10 @@ function isPathExcludedByList(excludedPaths, workspacePath) {
|
|
|
428
424
|
|
|
429
425
|
function isToolkitSourceDirectory(workspacePath) {
|
|
430
426
|
const packageJsonPath = path.join(workspacePath, "package.json");
|
|
431
|
-
const
|
|
427
|
+
const cliPath = path.join(workspacePath, "bin", "ling-cli.js");
|
|
432
428
|
const primaryCliPath = path.join(workspacePath, "bin", "ling.js");
|
|
433
429
|
|
|
434
|
-
if (!fs.existsSync(packageJsonPath) || (!fs.existsSync(
|
|
430
|
+
if (!fs.existsSync(packageJsonPath) || (!fs.existsSync(cliPath) && !fs.existsSync(primaryCliPath))) {
|
|
435
431
|
return false;
|
|
436
432
|
}
|
|
437
433
|
|
|
@@ -856,7 +852,7 @@ function maybeWarnUpstreamGlobalConflict(command, options) {
|
|
|
856
852
|
if (options.quiet) {
|
|
857
853
|
return;
|
|
858
854
|
}
|
|
859
|
-
if (process.env.LING_SKIP_UPSTREAM_CHECK === "1"
|
|
855
|
+
if (process.env.LING_SKIP_UPSTREAM_CHECK === "1") {
|
|
860
856
|
return;
|
|
861
857
|
}
|
|
862
858
|
const shouldWarn =
|
|
@@ -879,20 +875,8 @@ function maybeWarnUpstreamGlobalConflict(command, options) {
|
|
|
879
875
|
}
|
|
880
876
|
|
|
881
877
|
log(options, `[warn] 检测到全局已安装上游英文版 ${UPSTREAM_GLOBAL_PACKAGE}。`);
|
|
882
|
-
log(options, `[
|
|
883
|
-
log(options, `[
|
|
884
|
-
log(options, `[info] 正式命令已切换为 \`${PRIMARY_CLI_NAME}\`。若你通过 bun install -g 安装,Bun 默认会阻止本包 postinstall;因此这里会在首次执行 CLI 时再次提醒。`);
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function maybeWarnLegacyCliAlias(options) {
|
|
888
|
-
if (options.quiet) {
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
if (CURRENT_CLI_BASENAME !== LEGACY_CLI_NAME) {
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
console.log(`[warn] \`${LEGACY_CLI_NAME}\` 已进入兼容模式,请改用 \`${PRIMARY_CLI_NAME}\`.`);
|
|
878
|
+
log(options, `[hint] 如需仅保留一个来源,请执行: npm uninstall -g ${UPSTREAM_GLOBAL_PACKAGE}`);
|
|
879
|
+
log(options, `[info] 当前项目的正式命令为 \`${PRIMARY_CLI_NAME}\`。`);
|
|
896
880
|
}
|
|
897
881
|
|
|
898
882
|
function normalizeTargets(rawTargets) {
|
|
@@ -1219,9 +1203,106 @@ function syncGlobalSkillsFromRoot(targetName, skillsRoot, timestamp, options) {
|
|
|
1219
1203
|
};
|
|
1220
1204
|
}
|
|
1221
1205
|
|
|
1206
|
+
function planGlobalSyncTasks(targetName, agentDir) {
|
|
1207
|
+
const destinations = getGlobalDestinations(targetName);
|
|
1208
|
+
|
|
1209
|
+
if (targetName === "codex") {
|
|
1210
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ling-global-codex-"));
|
|
1211
|
+
const mockRoot = path.join(tempRoot, "source");
|
|
1212
|
+
const mockAgent = path.join(mockRoot, ".agents");
|
|
1213
|
+
const outputDir = path.join(tempRoot, "out");
|
|
1214
|
+
|
|
1215
|
+
copyDirRecursive(agentDir, mockAgent);
|
|
1216
|
+
CodexBuilder.build(mockRoot, outputDir);
|
|
1217
|
+
const skillsRoot = path.join(outputDir, "skills");
|
|
1218
|
+
const skillNames = listSkillDirectories(skillsRoot);
|
|
1219
|
+
|
|
1220
|
+
const tasks = [];
|
|
1221
|
+
for (const destination of destinations) {
|
|
1222
|
+
for (const skillName of skillNames) {
|
|
1223
|
+
tasks.push({
|
|
1224
|
+
destination,
|
|
1225
|
+
skillName,
|
|
1226
|
+
srcDir: path.join(skillsRoot, skillName),
|
|
1227
|
+
destDir: path.join(destination.skillsRoot, skillName),
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
return {
|
|
1233
|
+
tasks,
|
|
1234
|
+
cleanup: () => fs.rmSync(tempRoot, { recursive: true, force: true }),
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (targetName === "gemini") {
|
|
1239
|
+
const skillsRoot = path.join(agentDir, "skills");
|
|
1240
|
+
const skillNames = listSkillDirectories(skillsRoot);
|
|
1241
|
+
const tasks = [];
|
|
1242
|
+
for (const destination of destinations) {
|
|
1243
|
+
for (const skillName of skillNames) {
|
|
1244
|
+
tasks.push({
|
|
1245
|
+
destination,
|
|
1246
|
+
skillName,
|
|
1247
|
+
srcDir: path.join(skillsRoot, skillName),
|
|
1248
|
+
destDir: path.join(destination.skillsRoot, skillName),
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return { tasks, cleanup: null };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
throw new Error(`未知目标: ${targetName}`);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function summarizeGlobalSync(tasks) {
|
|
1259
|
+
const perDestination = new Map();
|
|
1260
|
+
let synced = 0;
|
|
1261
|
+
let skipped = 0;
|
|
1262
|
+
let backedUp = 0;
|
|
1263
|
+
|
|
1264
|
+
for (const task of tasks) {
|
|
1265
|
+
const id = task.destination.id;
|
|
1266
|
+
if (!perDestination.has(id)) {
|
|
1267
|
+
perDestination.set(id, {
|
|
1268
|
+
targetName: id,
|
|
1269
|
+
family: task.destination.targetName,
|
|
1270
|
+
destRoot: task.destination.skillsRoot,
|
|
1271
|
+
total: 0,
|
|
1272
|
+
synced: 0,
|
|
1273
|
+
skipped: 0,
|
|
1274
|
+
backedUp: 0,
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
const entry = perDestination.get(id);
|
|
1278
|
+
entry.total += 1;
|
|
1279
|
+
if (task.result === "skipped") {
|
|
1280
|
+
skipped += 1;
|
|
1281
|
+
entry.skipped += 1;
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (task.result === "synced") {
|
|
1285
|
+
synced += 1;
|
|
1286
|
+
entry.synced += 1;
|
|
1287
|
+
}
|
|
1288
|
+
if (task.backedUp) {
|
|
1289
|
+
backedUp += 1;
|
|
1290
|
+
entry.backedUp += 1;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
return {
|
|
1295
|
+
total: tasks.length,
|
|
1296
|
+
synced,
|
|
1297
|
+
skipped,
|
|
1298
|
+
backedUp,
|
|
1299
|
+
destinations: Array.from(perDestination.values()),
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1222
1303
|
function applyGlobalSync(targetName, agentDir, timestamp, options) {
|
|
1223
1304
|
if (targetName === "codex") {
|
|
1224
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "
|
|
1305
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ling-global-codex-"));
|
|
1225
1306
|
const mockRoot = path.join(tempRoot, "source");
|
|
1226
1307
|
const mockAgent = path.join(mockRoot, ".agents");
|
|
1227
1308
|
const outputDir = path.join(tempRoot, "out");
|
|
@@ -1248,21 +1329,70 @@ async function commandGlobalSync(options) {
|
|
|
1248
1329
|
const targets = await resolveTargetsForGlobalSync(options);
|
|
1249
1330
|
const { agentDir, cleanup, sourceLabel } = resolveAgentInstallSource(options);
|
|
1250
1331
|
const timestamp = nowISO().replace(/[:.]/g, "-");
|
|
1332
|
+
const prompter = createConflictPrompter(options);
|
|
1251
1333
|
|
|
1252
1334
|
try {
|
|
1253
1335
|
log(options, `[global] 全局同步源: ${sourceLabel}`);
|
|
1254
1336
|
for (const target of targets) {
|
|
1255
1337
|
log(options, `[sync] 正在同步全局目标 [${target}] ...`);
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1338
|
+
const plan = planGlobalSyncTasks(target, agentDir);
|
|
1339
|
+
try {
|
|
1340
|
+
for (const task of plan.tasks) {
|
|
1341
|
+
const exists = fs.existsSync(task.destDir);
|
|
1342
|
+
const equal = exists ? areDirectoriesEqual(task.srcDir, task.destDir) : false;
|
|
1343
|
+
if (exists && equal) {
|
|
1344
|
+
task.result = "skipped";
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (exists && !equal && prompter) {
|
|
1349
|
+
const action = await prompter.resolveConflict({
|
|
1350
|
+
category: `global:${task.destination.id}`,
|
|
1351
|
+
label: `全局 Skill ${task.destination.id}/${task.skillName}`,
|
|
1352
|
+
path: task.destDir,
|
|
1353
|
+
});
|
|
1354
|
+
task.action = action;
|
|
1355
|
+
} else if (exists && !equal && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
|
|
1356
|
+
// 非交互环境保持原有行为:备份后覆盖
|
|
1357
|
+
task.action = "backup";
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if (task.action === "keep") {
|
|
1361
|
+
task.result = "skipped";
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (options.dryRun) {
|
|
1366
|
+
log(options, `[dry-run] 将同步全局 Skill: ${task.destination.id}/${task.skillName}`);
|
|
1367
|
+
task.result = "skipped";
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
if (exists && task.action !== "remove") {
|
|
1372
|
+
backupSkillDirectory(task.destination.id, task.skillName, task.destDir, timestamp, options);
|
|
1373
|
+
task.backedUp = true;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const logger = options.quiet ? (() => {}) : log.bind(null, options);
|
|
1377
|
+
AtomicWriter.atomicCopyDir(task.srcDir, task.destDir, { logger });
|
|
1378
|
+
log(options, `[ok] 已同步全局 Skill: ${task.destination.id}/${task.skillName}`);
|
|
1379
|
+
task.result = "synced";
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const summary = summarizeGlobalSync(plan.tasks);
|
|
1383
|
+
if (!options.dryRun) {
|
|
1384
|
+
log(options, `[summary] 全局同步完成 [${target}]:总计 ${summary.total},新增/覆盖 ${summary.synced},跳过 ${summary.skipped},备份 ${summary.backedUp}`);
|
|
1385
|
+
for (const item of summary.destinations) {
|
|
1386
|
+
log(options, ` - ${item.targetName}: ${item.destRoot}(每目标 ${item.total} 个 Skills)`);
|
|
1387
|
+
}
|
|
1261
1388
|
}
|
|
1389
|
+
} finally {
|
|
1390
|
+
if (plan.cleanup) plan.cleanup();
|
|
1262
1391
|
}
|
|
1263
1392
|
}
|
|
1264
1393
|
} finally {
|
|
1265
1394
|
if (cleanup) cleanup();
|
|
1395
|
+
if (prompter) prompter.close();
|
|
1266
1396
|
}
|
|
1267
1397
|
}
|
|
1268
1398
|
|
|
@@ -1324,6 +1454,10 @@ function getSpecHomeDir() {
|
|
|
1324
1454
|
return path.join(resolveGlobalRootDir(), ".ling", "spec");
|
|
1325
1455
|
}
|
|
1326
1456
|
|
|
1457
|
+
function getSpecWorkspaceDir() {
|
|
1458
|
+
return path.join(resolveGlobalRootDir(), ".ling", "spec-workspace");
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1327
1461
|
function getSpecStatePath() {
|
|
1328
1462
|
return path.join(getSpecHomeDir(), "state.json");
|
|
1329
1463
|
}
|
|
@@ -1401,6 +1535,46 @@ function ensureBundledSpecResources() {
|
|
|
1401
1535
|
}
|
|
1402
1536
|
}
|
|
1403
1537
|
|
|
1538
|
+
function listMissingFiles(rootDir, requiredRelPaths) {
|
|
1539
|
+
if (!fs.existsSync(rootDir)) {
|
|
1540
|
+
return requiredRelPaths.map((rel) => rel);
|
|
1541
|
+
}
|
|
1542
|
+
return requiredRelPaths.filter((rel) => !fs.existsSync(path.join(rootDir, ...rel.split("/"))));
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function collectSpecOrphanIssues(specHome, hasStateFile) {
|
|
1546
|
+
const issues = [];
|
|
1547
|
+
const templatesDir = path.join(specHome, "templates");
|
|
1548
|
+
const referencesDir = path.join(specHome, "references");
|
|
1549
|
+
const profilesDir = path.join(specHome, "profiles");
|
|
1550
|
+
|
|
1551
|
+
if (fs.existsSync(templatesDir)) issues.push("Detected spec templates directory");
|
|
1552
|
+
if (fs.existsSync(referencesDir)) issues.push("Detected spec references directory");
|
|
1553
|
+
if (fs.existsSync(profilesDir)) issues.push("Detected spec profiles directory");
|
|
1554
|
+
|
|
1555
|
+
for (const targetName of SUPPORTED_TARGETS) {
|
|
1556
|
+
for (const destination of getGlobalDestinations(targetName)) {
|
|
1557
|
+
for (const skillName of SPEC_SKILL_NAMES) {
|
|
1558
|
+
if (fs.existsSync(path.join(destination.skillsRoot, skillName, "SKILL.md"))) {
|
|
1559
|
+
issues.push(`Detected spec skill: ${destination.id}/${skillName}`);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
if (issues.length === 0) {
|
|
1566
|
+
return [];
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
if (!hasStateFile) {
|
|
1570
|
+
issues.unshift("Spec artifacts detected but state.json missing (run: ling spec enable to repair)");
|
|
1571
|
+
} else {
|
|
1572
|
+
issues.unshift("Spec artifacts detected but no targets enabled (run: ling spec enable to reconcile or clean manually)");
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
return issues;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1404
1578
|
function backupDirSnapshot(sourceDir, backupDir, options, label) {
|
|
1405
1579
|
if (!fs.existsSync(sourceDir)) {
|
|
1406
1580
|
return "";
|
|
@@ -1437,7 +1611,60 @@ function removeDirIfExists(targetDir, options, label) {
|
|
|
1437
1611
|
log(options, `[clean] 已删除 ${label}: ${targetDir}`);
|
|
1438
1612
|
}
|
|
1439
1613
|
|
|
1440
|
-
function
|
|
1614
|
+
function atomicWriteFile(targetPath, content, options, label) {
|
|
1615
|
+
const targetDir = path.dirname(targetPath);
|
|
1616
|
+
if (options.dryRun) {
|
|
1617
|
+
log(options, `[dry-run] 将写入 ${label}: ${targetPath}`);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
1621
|
+
const tempPath = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1622
|
+
fs.writeFileSync(tempPath, content, "utf8");
|
|
1623
|
+
try {
|
|
1624
|
+
if (fs.existsSync(targetPath)) {
|
|
1625
|
+
const backupPath = `${targetPath}.bak-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`;
|
|
1626
|
+
fs.renameSync(targetPath, backupPath);
|
|
1627
|
+
try {
|
|
1628
|
+
fs.renameSync(tempPath, targetPath);
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
try {
|
|
1631
|
+
fs.renameSync(backupPath, targetPath);
|
|
1632
|
+
} catch (restoreErr) {
|
|
1633
|
+
throw new Error(`临界失败: 无法将新版本写入目标且无法恢复旧版本。旧版本位于 ${backupPath}。错误: ${err.message}`);
|
|
1634
|
+
}
|
|
1635
|
+
throw err;
|
|
1636
|
+
}
|
|
1637
|
+
try {
|
|
1638
|
+
fs.rmSync(backupPath, { force: true });
|
|
1639
|
+
} catch (cleanupErr) {
|
|
1640
|
+
log(options, `[warn] 无法清理备份文件 ${backupPath}: ${cleanupErr.message}`);
|
|
1641
|
+
}
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
fs.renameSync(tempPath, targetPath);
|
|
1645
|
+
} catch (err) {
|
|
1646
|
+
if (fs.existsSync(tempPath)) {
|
|
1647
|
+
fs.rmSync(tempPath, { force: true });
|
|
1648
|
+
}
|
|
1649
|
+
throw new Error(`原子写入失败: ${err.message}`);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function backupFileSnapshot(sourcePath, backupPath, options, label) {
|
|
1654
|
+
if (!fs.existsSync(sourcePath)) {
|
|
1655
|
+
return "";
|
|
1656
|
+
}
|
|
1657
|
+
if (options.dryRun) {
|
|
1658
|
+
log(options, `[dry-run] 将备份 ${label}: ${sourcePath} -> ${backupPath}`);
|
|
1659
|
+
return backupPath;
|
|
1660
|
+
}
|
|
1661
|
+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
1662
|
+
fs.copyFileSync(sourcePath, backupPath);
|
|
1663
|
+
log(options, `[backup] 已备份 ${label}: ${sourcePath} -> ${backupPath}`);
|
|
1664
|
+
return backupPath;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
async function ensureSpecAssetsInstalled(state, timestamp, options, prompter) {
|
|
1441
1668
|
const specHome = getSpecHomeDir();
|
|
1442
1669
|
const assets = {
|
|
1443
1670
|
templates: {
|
|
@@ -1448,32 +1675,102 @@ function ensureSpecAssetsInstalled(state, timestamp, options) {
|
|
|
1448
1675
|
sourceDir: path.join(BUNDLED_SPEC_DIR, "references"),
|
|
1449
1676
|
destDir: path.join(specHome, "references"),
|
|
1450
1677
|
},
|
|
1678
|
+
profiles: {
|
|
1679
|
+
sourceDir: path.join(BUNDLED_SPEC_DIR, "profiles"),
|
|
1680
|
+
destDir: path.join(specHome, "profiles"),
|
|
1681
|
+
},
|
|
1451
1682
|
};
|
|
1452
1683
|
|
|
1453
1684
|
for (const [assetName, config] of Object.entries(assets)) {
|
|
1454
|
-
|
|
1685
|
+
const existingAssetState = state.assets[assetName];
|
|
1686
|
+
const hasState = existingAssetState && existingAssetState.installedAt;
|
|
1687
|
+
const exists = fs.existsSync(config.destDir);
|
|
1688
|
+
const mode = normalizeSpecAssetMode(existingAssetState);
|
|
1689
|
+
|
|
1690
|
+
if (mode === "kept" && exists) {
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const equal = exists ? areDirectoriesEqual(config.sourceDir, config.destDir) : false;
|
|
1695
|
+
|
|
1696
|
+
if (exists && equal) {
|
|
1697
|
+
if (!hasState) {
|
|
1698
|
+
log(options, `[skip] Spec ${assetName} 已存在且一致,视为已启用: ${config.destDir}`);
|
|
1699
|
+
state.assets[assetName] = {
|
|
1700
|
+
destPath: config.destDir,
|
|
1701
|
+
backupPath: "",
|
|
1702
|
+
installedAt: nowISO(),
|
|
1703
|
+
mode: "kept",
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
let action = exists ? "backup" : "";
|
|
1710
|
+
if (exists && prompter) {
|
|
1711
|
+
action = await prompter.resolveConflict({
|
|
1712
|
+
category: "spec:assets",
|
|
1713
|
+
label: `Spec ${assetName}`,
|
|
1714
|
+
path: config.destDir,
|
|
1715
|
+
});
|
|
1716
|
+
} else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
|
|
1717
|
+
action = "backup";
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
if (action === "keep") {
|
|
1721
|
+
log(options, `[skip] 已保留 Spec ${assetName} 资产,不覆盖: ${config.destDir}`);
|
|
1722
|
+
state.assets[assetName] = {
|
|
1723
|
+
destPath: config.destDir,
|
|
1724
|
+
backupPath: "",
|
|
1725
|
+
installedAt: nowISO(),
|
|
1726
|
+
mode: "kept",
|
|
1727
|
+
};
|
|
1455
1728
|
continue;
|
|
1456
1729
|
}
|
|
1457
1730
|
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1731
|
+
let backupPath = "";
|
|
1732
|
+
if (exists && action !== "remove") {
|
|
1733
|
+
backupPath = backupDirSnapshot(
|
|
1734
|
+
config.destDir,
|
|
1735
|
+
path.join(resolveSpecBackupRoot(timestamp), "assets", assetName),
|
|
1736
|
+
options,
|
|
1737
|
+
`Spec ${assetName}`,
|
|
1738
|
+
);
|
|
1739
|
+
} else if (exists && action === "remove") {
|
|
1740
|
+
removeDirIfExists(config.destDir, options, `Spec ${assetName}`);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1464
1743
|
applyDirSnapshot(config.sourceDir, config.destDir, options, `Spec ${assetName}`);
|
|
1465
1744
|
state.assets[assetName] = {
|
|
1466
1745
|
destPath: config.destDir,
|
|
1467
1746
|
backupPath,
|
|
1468
1747
|
installedAt: nowISO(),
|
|
1748
|
+
mode: exists ? (action === "remove" ? "replaced" : "backup") : "created",
|
|
1469
1749
|
};
|
|
1470
1750
|
}
|
|
1471
1751
|
}
|
|
1472
1752
|
|
|
1753
|
+
function normalizeSpecAssetMode(assetState) {
|
|
1754
|
+
if (!assetState || typeof assetState !== "object") {
|
|
1755
|
+
return "";
|
|
1756
|
+
}
|
|
1757
|
+
if (typeof assetState.mode === "string" && assetState.mode) {
|
|
1758
|
+
return assetState.mode;
|
|
1759
|
+
}
|
|
1760
|
+
if (typeof assetState.backupPath === "string" && assetState.backupPath) {
|
|
1761
|
+
return "backup";
|
|
1762
|
+
}
|
|
1763
|
+
return "created";
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1473
1766
|
function restoreSpecAsset(assetState, options, label) {
|
|
1474
1767
|
if (!assetState || typeof assetState.destPath !== "string") {
|
|
1475
1768
|
return;
|
|
1476
1769
|
}
|
|
1770
|
+
const mode = normalizeSpecAssetMode(assetState);
|
|
1771
|
+
if (mode === "kept") {
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1477
1774
|
if (assetState.backupPath && fs.existsSync(assetState.backupPath)) {
|
|
1478
1775
|
applyDirSnapshot(assetState.backupPath, assetState.destPath, options, label);
|
|
1479
1776
|
return;
|
|
@@ -1481,37 +1778,100 @@ function restoreSpecAsset(assetState, options, label) {
|
|
|
1481
1778
|
removeDirIfExists(assetState.destPath, options, label);
|
|
1482
1779
|
}
|
|
1483
1780
|
|
|
1484
|
-
function enableSpecTarget(targetName, state, timestamp, options) {
|
|
1485
|
-
if (state.targets[targetName]) {
|
|
1486
|
-
log(options, `[skip] Spec 目标已启用: ${targetName}`);
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1781
|
+
async function enableSpecTarget(targetName, state, timestamp, options, prompter) {
|
|
1490
1782
|
const destinations = getGlobalDestinations(targetName);
|
|
1491
|
-
const
|
|
1783
|
+
const existingTargetState = state.targets[targetName];
|
|
1784
|
+
if (existingTargetState) {
|
|
1785
|
+
log(options, `[info] Spec 目标已启用,执行一致性修复: ${targetName}`);
|
|
1786
|
+
}
|
|
1787
|
+
const targetState = existingTargetState || {
|
|
1492
1788
|
enabledAt: nowISO(),
|
|
1493
1789
|
consumers: {},
|
|
1494
1790
|
};
|
|
1495
1791
|
|
|
1496
1792
|
for (const destination of destinations) {
|
|
1793
|
+
const existingConsumerState =
|
|
1794
|
+
targetState.consumers && targetState.consumers[destination.id] ? targetState.consumers[destination.id] : null;
|
|
1497
1795
|
const consumerState = {
|
|
1498
1796
|
skills: [],
|
|
1499
1797
|
};
|
|
1798
|
+
const existingSkills = new Map();
|
|
1799
|
+
if (existingConsumerState && Array.isArray(existingConsumerState.skills)) {
|
|
1800
|
+
for (const skill of existingConsumerState.skills) {
|
|
1801
|
+
if (skill && typeof skill.name === "string") {
|
|
1802
|
+
existingSkills.set(skill.name, skill);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1500
1806
|
|
|
1501
1807
|
for (const skillName of SPEC_SKILL_NAMES) {
|
|
1502
1808
|
const srcDir = path.join(BUNDLED_SPEC_DIR, "skills", skillName);
|
|
1503
1809
|
const destDir = path.join(destination.skillsRoot, skillName);
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
)
|
|
1810
|
+
const existingSkillState = existingSkills.get(skillName);
|
|
1811
|
+
const mode = normalizeSpecAssetMode(existingSkillState);
|
|
1812
|
+
const exists = fs.existsSync(destDir);
|
|
1813
|
+
const equal = exists ? areDirectoriesEqual(srcDir, destDir) : false;
|
|
1814
|
+
|
|
1815
|
+
if (mode === "kept" && exists) {
|
|
1816
|
+
consumerState.skills.push({
|
|
1817
|
+
name: skillName,
|
|
1818
|
+
destPath: destDir,
|
|
1819
|
+
backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
|
|
1820
|
+
mode: "kept",
|
|
1821
|
+
});
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
if (exists && equal) {
|
|
1826
|
+
consumerState.skills.push({
|
|
1827
|
+
name: skillName,
|
|
1828
|
+
destPath: destDir,
|
|
1829
|
+
backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
|
|
1830
|
+
mode: existingSkillState && existingSkillState.mode ? existingSkillState.mode : "kept",
|
|
1831
|
+
});
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
let action = exists ? "backup" : "";
|
|
1836
|
+
if (exists && prompter) {
|
|
1837
|
+
action = await prompter.resolveConflict({
|
|
1838
|
+
category: `spec:skills:${destination.id}`,
|
|
1839
|
+
label: `Spec Skill ${destination.id}/${skillName}`,
|
|
1840
|
+
path: destDir,
|
|
1841
|
+
});
|
|
1842
|
+
} else if (exists && !prompter && (options.nonInteractive || !process.stdin.isTTY)) {
|
|
1843
|
+
action = "backup";
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
if (action === "keep") {
|
|
1847
|
+
log(options, `[skip] 已保留 Spec Skill,不覆盖: ${destination.id}/${skillName}`);
|
|
1848
|
+
consumerState.skills.push({
|
|
1849
|
+
name: skillName,
|
|
1850
|
+
destPath: destDir,
|
|
1851
|
+
backupPath: existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "",
|
|
1852
|
+
mode: "kept",
|
|
1853
|
+
});
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
let backupPath = existingSkillState && existingSkillState.backupPath ? existingSkillState.backupPath : "";
|
|
1858
|
+
if (exists && action !== "remove") {
|
|
1859
|
+
backupPath = backupDirSnapshot(
|
|
1860
|
+
destDir,
|
|
1861
|
+
path.join(resolveSpecBackupRoot(timestamp), destination.id, "skills", skillName),
|
|
1862
|
+
options,
|
|
1863
|
+
`Spec Skill ${destination.id}/${skillName}`,
|
|
1864
|
+
);
|
|
1865
|
+
} else if (exists && action === "remove") {
|
|
1866
|
+
removeDirIfExists(destDir, options, `Spec Skill ${destination.id}/${skillName}`);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1510
1869
|
applyDirSnapshot(srcDir, destDir, options, `Spec Skill ${destination.id}/${skillName}`);
|
|
1511
1870
|
consumerState.skills.push({
|
|
1512
1871
|
name: skillName,
|
|
1513
1872
|
destPath: destDir,
|
|
1514
1873
|
backupPath,
|
|
1874
|
+
mode: exists ? (action === "remove" ? "replaced" : "backup") : "created",
|
|
1515
1875
|
});
|
|
1516
1876
|
}
|
|
1517
1877
|
|
|
@@ -1530,6 +1890,10 @@ function disableSpecTarget(targetName, state, options) {
|
|
|
1530
1890
|
|
|
1531
1891
|
for (const [consumerId, consumerState] of Object.entries(targetState.consumers || {})) {
|
|
1532
1892
|
for (const skill of consumerState.skills || []) {
|
|
1893
|
+
const mode = normalizeSpecAssetMode(skill);
|
|
1894
|
+
if (mode === "kept") {
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1533
1897
|
if (skill.backupPath && fs.existsSync(skill.backupPath)) {
|
|
1534
1898
|
applyDirSnapshot(skill.backupPath, skill.destPath, options, `恢复 Spec Skill ${consumerId}/${skill.name}`);
|
|
1535
1899
|
} else {
|
|
@@ -1542,14 +1906,28 @@ function disableSpecTarget(targetName, state, options) {
|
|
|
1542
1906
|
}
|
|
1543
1907
|
|
|
1544
1908
|
function evaluateSpecState() {
|
|
1909
|
+
const statePath = getSpecStatePath();
|
|
1910
|
+
const hasStateFile = fs.existsSync(statePath);
|
|
1545
1911
|
const { state } = readSpecState();
|
|
1546
1912
|
const targetNames = Object.keys(state.targets || {});
|
|
1547
1913
|
if (targetNames.length === 0) {
|
|
1914
|
+
const specHome = getSpecHomeDir();
|
|
1915
|
+
const orphanIssues = collectSpecOrphanIssues(specHome, hasStateFile);
|
|
1916
|
+
if (orphanIssues.length > 0) {
|
|
1917
|
+
return {
|
|
1918
|
+
state: "broken",
|
|
1919
|
+
targets: [],
|
|
1920
|
+
assets: state.assets || {},
|
|
1921
|
+
specHome,
|
|
1922
|
+
issues: orphanIssues,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1548
1925
|
return {
|
|
1549
1926
|
state: "missing",
|
|
1550
1927
|
targets: [],
|
|
1551
1928
|
assets: state.assets || {},
|
|
1552
|
-
specHome
|
|
1929
|
+
specHome,
|
|
1930
|
+
issues: [],
|
|
1553
1931
|
};
|
|
1554
1932
|
}
|
|
1555
1933
|
|
|
@@ -1565,10 +1943,23 @@ function evaluateSpecState() {
|
|
|
1565
1943
|
}
|
|
1566
1944
|
}
|
|
1567
1945
|
|
|
1568
|
-
|
|
1946
|
+
const specHome = getSpecHomeDir();
|
|
1947
|
+
const assetRequirements = {
|
|
1948
|
+
templates: { dir: path.join(specHome, "templates"), files: SPEC_TEMPLATE_REQUIRED_FILES },
|
|
1949
|
+
references: { dir: path.join(specHome, "references"), files: SPEC_REFERENCE_REQUIRED_FILES },
|
|
1950
|
+
profiles: { dir: path.join(specHome, "profiles"), files: SPEC_PROFILE_REQUIRED_FILES },
|
|
1951
|
+
};
|
|
1952
|
+
|
|
1953
|
+
for (const assetName of ["templates", "references", "profiles"]) {
|
|
1569
1954
|
const asset = state.assets[assetName];
|
|
1570
1955
|
if (!asset || !asset.destPath || !fs.existsSync(asset.destPath)) {
|
|
1571
1956
|
issues.push(`Missing spec asset: ${assetName}`);
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
const requirement = assetRequirements[assetName];
|
|
1960
|
+
const missing = listMissingFiles(requirement.dir, requirement.files);
|
|
1961
|
+
for (const rel of missing) {
|
|
1962
|
+
issues.push(`Missing spec asset file: ${assetName}/${rel}`);
|
|
1572
1963
|
}
|
|
1573
1964
|
}
|
|
1574
1965
|
|
|
@@ -1577,7 +1968,7 @@ function evaluateSpecState() {
|
|
|
1577
1968
|
targets: targetNames,
|
|
1578
1969
|
issues,
|
|
1579
1970
|
assets: state.assets || {},
|
|
1580
|
-
specHome
|
|
1971
|
+
specHome,
|
|
1581
1972
|
};
|
|
1582
1973
|
}
|
|
1583
1974
|
|
|
@@ -1608,15 +1999,308 @@ function commandSpecStatus(options) {
|
|
|
1608
1999
|
setQuietStatusExitCode(summary.state);
|
|
1609
2000
|
}
|
|
1610
2001
|
|
|
1611
|
-
function
|
|
2002
|
+
function stripUtf8Bom(text) {
|
|
2003
|
+
if (!text) return "";
|
|
2004
|
+
return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function parseCsvLine(line) {
|
|
2008
|
+
const cells = [];
|
|
2009
|
+
let current = "";
|
|
2010
|
+
let inQuotes = false;
|
|
2011
|
+
|
|
2012
|
+
for (let i = 0; i < line.length; i++) {
|
|
2013
|
+
const ch = line[i];
|
|
2014
|
+
if (ch === "\"") {
|
|
2015
|
+
if (inQuotes && line[i + 1] === "\"") {
|
|
2016
|
+
current += "\"";
|
|
2017
|
+
i += 1;
|
|
2018
|
+
continue;
|
|
2019
|
+
}
|
|
2020
|
+
inQuotes = !inQuotes;
|
|
2021
|
+
continue;
|
|
2022
|
+
}
|
|
2023
|
+
if (ch === "," && !inQuotes) {
|
|
2024
|
+
cells.push(current);
|
|
2025
|
+
current = "";
|
|
2026
|
+
continue;
|
|
2027
|
+
}
|
|
2028
|
+
current += ch;
|
|
2029
|
+
}
|
|
2030
|
+
cells.push(current);
|
|
2031
|
+
return cells;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function analyzeIssuesCsv(issuesPath) {
|
|
2035
|
+
if (!fs.existsSync(issuesPath)) {
|
|
2036
|
+
return { status: "missing", issues: ["Missing issues.csv"], stats: { total: 0, inProgress: 0 } };
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
const raw = stripUtf8Bom(fs.readFileSync(issuesPath, "utf8"));
|
|
2040
|
+
const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
2041
|
+
if (lines.length === 0) {
|
|
2042
|
+
return { status: "broken", issues: ["issues.csv is empty"], stats: { total: 0, inProgress: 0 } };
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
const header = parseCsvLine(lines[0]).map((cell) => cell.trim());
|
|
2046
|
+
const statusIndex = header.findIndex((cell) => cell === "状态" || cell.includes("(状态)") || /状态/.test(cell));
|
|
2047
|
+
if (statusIndex < 0) {
|
|
2048
|
+
return { status: "broken", issues: ["issues.csv header missing 状态 column"], stats: { total: Math.max(0, lines.length - 1), inProgress: 0 } };
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
const allowedStates = new Set(["未开始", "进行中", "已完成"]);
|
|
2052
|
+
let total = 0;
|
|
2053
|
+
let inProgress = 0;
|
|
2054
|
+
const issues = [];
|
|
2055
|
+
|
|
2056
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2057
|
+
const row = parseCsvLine(lines[i]);
|
|
2058
|
+
const isEmpty = row.every((cell) => String(cell || "").trim() === "");
|
|
2059
|
+
if (isEmpty) {
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
total += 1;
|
|
2063
|
+
const state = String(row[statusIndex] || "").trim();
|
|
2064
|
+
if (!allowedStates.has(state)) {
|
|
2065
|
+
issues.push(`Invalid 状态 at row ${i + 1}: ${state || "(empty)"}`);
|
|
2066
|
+
continue;
|
|
2067
|
+
}
|
|
2068
|
+
if (state === "进行中") {
|
|
2069
|
+
inProgress += 1;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
if (inProgress > 1) {
|
|
2074
|
+
issues.push(`Multiple tasks in 进行中: ${inProgress}`);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
return {
|
|
2078
|
+
status: issues.length > 0 ? "broken" : "ok",
|
|
2079
|
+
issues,
|
|
2080
|
+
stats: { total, inProgress },
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
function checkSpecProjectIntegrity(workspaceRoot) {
|
|
2085
|
+
const issues = [];
|
|
2086
|
+
const issuesPath = path.join(workspaceRoot, "issues.csv");
|
|
2087
|
+
const issuesResult = analyzeIssuesCsv(issuesPath);
|
|
2088
|
+
|
|
2089
|
+
const specDir = path.join(workspaceRoot, ".ling", "spec");
|
|
2090
|
+
const templatesDir = path.join(specDir, "templates");
|
|
2091
|
+
const referencesDir = path.join(specDir, "references");
|
|
2092
|
+
const profilesDir = path.join(specDir, "profiles");
|
|
2093
|
+
|
|
2094
|
+
const hasSpecDir = fs.existsSync(specDir);
|
|
2095
|
+
if (!hasSpecDir) {
|
|
2096
|
+
if (issuesResult.status !== "missing") {
|
|
2097
|
+
issues.push("Missing .ling/spec directory (run: ling spec init)");
|
|
2098
|
+
}
|
|
2099
|
+
} else {
|
|
2100
|
+
if (issuesResult.status === "missing") {
|
|
2101
|
+
issues.push("Missing issues.csv (run: ling spec init)");
|
|
2102
|
+
}
|
|
2103
|
+
for (const rel of SPEC_TEMPLATE_REQUIRED_FILES) {
|
|
2104
|
+
const filePath = path.join(templatesDir, rel);
|
|
2105
|
+
if (!fs.existsSync(filePath)) {
|
|
2106
|
+
issues.push(`Missing spec template: .ling/spec/templates/${rel}`);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
for (const rel of SPEC_REFERENCE_REQUIRED_FILES) {
|
|
2110
|
+
const filePath = path.join(referencesDir, rel);
|
|
2111
|
+
if (!fs.existsSync(filePath)) {
|
|
2112
|
+
issues.push(`Missing spec reference: .ling/spec/references/${rel}`);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
for (const rel of SPEC_PROFILE_REQUIRED_FILES) {
|
|
2116
|
+
const filePath = path.join(profilesDir, ...rel.split("/"));
|
|
2117
|
+
if (!fs.existsSync(filePath)) {
|
|
2118
|
+
issues.push(`Missing spec profile: .ling/spec/profiles/${rel}`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
if (issuesResult.status === "broken") {
|
|
2124
|
+
issues.push(...issuesResult.issues);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
const hasAnySpecSignal = hasSpecDir || issuesResult.status !== "missing";
|
|
2128
|
+
if (!hasAnySpecSignal) {
|
|
2129
|
+
return { status: "missing", issues: [], stats: { total: 0, inProgress: 0 } };
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
return {
|
|
2133
|
+
status: issues.length > 0 ? "broken" : "ok",
|
|
2134
|
+
issues,
|
|
2135
|
+
stats: issuesResult.stats,
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
function resolveWorkspaceSpecBackupRoot(workspaceRoot, timestamp) {
|
|
2140
|
+
return path.join(workspaceRoot, ".ling", "backups", "spec", timestamp, "before");
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
async function commandSpecInit(options) {
|
|
2144
|
+
const workspaceRoot = options.path ? resolveWorkspaceRoot(options.path) : getSpecWorkspaceDir();
|
|
2145
|
+
const prompter = createConflictPrompter(options);
|
|
2146
|
+
const timestamp = nowISO().replace(/[:.]/g, "-");
|
|
2147
|
+
const backupRoot = resolveWorkspaceSpecBackupRoot(workspaceRoot, timestamp);
|
|
2148
|
+
|
|
2149
|
+
const specDir = path.join(workspaceRoot, ".ling", "spec");
|
|
2150
|
+
let specSourceDir = BUNDLED_SPEC_DIR;
|
|
2151
|
+
let cleanupSpec = null;
|
|
2152
|
+
if (options.branch) {
|
|
2153
|
+
const remote = cloneBranchSpecDir(options.branch, {
|
|
2154
|
+
quiet: options.quiet,
|
|
2155
|
+
logger: log.bind(null, options),
|
|
2156
|
+
});
|
|
2157
|
+
specSourceDir = remote.specDir;
|
|
2158
|
+
cleanupSpec = remote.cleanup;
|
|
2159
|
+
}
|
|
2160
|
+
const assets = {
|
|
2161
|
+
templates: {
|
|
2162
|
+
sourceDir: path.join(specSourceDir, "templates"),
|
|
2163
|
+
destDir: path.join(specDir, "templates"),
|
|
2164
|
+
},
|
|
2165
|
+
references: {
|
|
2166
|
+
sourceDir: path.join(specSourceDir, "references"),
|
|
2167
|
+
destDir: path.join(specDir, "references"),
|
|
2168
|
+
},
|
|
2169
|
+
profiles: {
|
|
2170
|
+
sourceDir: path.join(specSourceDir, "profiles"),
|
|
2171
|
+
destDir: path.join(specDir, "profiles"),
|
|
2172
|
+
},
|
|
2173
|
+
};
|
|
2174
|
+
|
|
2175
|
+
try {
|
|
2176
|
+
fs.mkdirSync(workspaceRoot, { recursive: true });
|
|
2177
|
+
|
|
2178
|
+
for (const [assetName, config] of Object.entries(assets)) {
|
|
2179
|
+
const exists = fs.existsSync(config.destDir);
|
|
2180
|
+
if (exists && areDirectoriesEqual(config.sourceDir, config.destDir)) {
|
|
2181
|
+
log(options, `[skip] Spec ${assetName} 已最新,无需覆盖: ${config.destDir}`);
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
let action = exists ? "backup" : "";
|
|
2186
|
+
if (exists && prompter) {
|
|
2187
|
+
action = await prompter.resolveConflict({
|
|
2188
|
+
category: "spec:project:assets",
|
|
2189
|
+
label: `Spec ${assetName}`,
|
|
2190
|
+
path: config.destDir,
|
|
2191
|
+
});
|
|
2192
|
+
} else if (exists && !prompter && (options.force || options.nonInteractive || !process.stdin.isTTY)) {
|
|
2193
|
+
action = "backup";
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
if (action === "keep") {
|
|
2197
|
+
log(options, `[skip] 已保留 Spec ${assetName} 资产,不覆盖: ${config.destDir}`);
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
if (exists && action !== "remove") {
|
|
2202
|
+
backupDirSnapshot(config.destDir, path.join(backupRoot, "assets", assetName), options, `Spec ${assetName}`);
|
|
2203
|
+
} else if (exists && action === "remove") {
|
|
2204
|
+
removeDirIfExists(config.destDir, options, `Spec ${assetName}`);
|
|
2205
|
+
}
|
|
2206
|
+
applyDirSnapshot(config.sourceDir, config.destDir, options, `Spec ${assetName}`);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const issuesPath = path.join(workspaceRoot, "issues.csv");
|
|
2210
|
+
const issuesTemplatePath = path.join(specSourceDir, "templates", "issues.template.csv");
|
|
2211
|
+
const issuesTemplate = stripUtf8Bom(fs.readFileSync(issuesTemplatePath, "utf8"));
|
|
2212
|
+
const hasIssues = fs.existsSync(issuesPath);
|
|
2213
|
+
if (hasIssues) {
|
|
2214
|
+
let action = "backup";
|
|
2215
|
+
if (prompter) {
|
|
2216
|
+
action = await prompter.resolveConflict({
|
|
2217
|
+
category: "spec:project:file",
|
|
2218
|
+
label: "issues.csv",
|
|
2219
|
+
path: issuesPath,
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
if (action === "keep") {
|
|
2223
|
+
log(options, "[skip] 已保留现有 issues.csv,不覆盖");
|
|
2224
|
+
} else {
|
|
2225
|
+
if (action !== "remove") {
|
|
2226
|
+
backupFileSnapshot(issuesPath, path.join(backupRoot, "issues.csv"), options, "issues.csv");
|
|
2227
|
+
} else if (!options.dryRun) {
|
|
2228
|
+
fs.rmSync(issuesPath, { force: true });
|
|
2229
|
+
}
|
|
2230
|
+
atomicWriteFile(issuesPath, `${issuesTemplate.trimEnd()}\n`, options, "issues.csv");
|
|
2231
|
+
log(options, options.dryRun ? `[dry-run] 将写入任务跟踪文件: ${issuesPath}` : `[ok] 已写入任务跟踪文件: ${issuesPath}`);
|
|
2232
|
+
}
|
|
2233
|
+
} else {
|
|
2234
|
+
atomicWriteFile(issuesPath, `${issuesTemplate.trimEnd()}\n`, options, "issues.csv");
|
|
2235
|
+
log(options, options.dryRun ? `[dry-run] 将创建任务跟踪文件: ${issuesPath}` : `[ok] 已创建任务跟踪文件: ${issuesPath}`);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
const docsReviewsDir = path.join(workspaceRoot, "docs", "reviews");
|
|
2239
|
+
const docsHandoffDir = path.join(workspaceRoot, "docs", "handoff");
|
|
2240
|
+
if (options.dryRun) {
|
|
2241
|
+
log(options, `[dry-run] 将确保目录存在: ${docsReviewsDir}`);
|
|
2242
|
+
log(options, `[dry-run] 将确保目录存在: ${docsHandoffDir}`);
|
|
2243
|
+
} else {
|
|
2244
|
+
fs.mkdirSync(docsReviewsDir, { recursive: true });
|
|
2245
|
+
fs.mkdirSync(docsHandoffDir, { recursive: true });
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
const requestedTargets = normalizeTargets(options.targets);
|
|
2249
|
+
const shouldInitTargets = options.path ? requestedTargets.length > 0 : true;
|
|
2250
|
+
const targets = shouldInitTargets ? (requestedTargets.length > 0 ? requestedTargets : [...SUPPORTED_TARGETS]) : [];
|
|
2251
|
+
if (targets.length > 0) {
|
|
2252
|
+
await initTargets(workspaceRoot, targets, options, prompter);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
log(options, `[ok] Spec 初始化完成: ${workspaceRoot}`);
|
|
2256
|
+
} finally {
|
|
2257
|
+
if (cleanupSpec) cleanupSpec();
|
|
2258
|
+
if (prompter) prompter.close();
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function commandSpecDoctor(options) {
|
|
2263
|
+
const workspaceRoot = options.path ? resolveWorkspaceRoot(options.path) : getSpecWorkspaceDir();
|
|
2264
|
+
const result = checkSpecProjectIntegrity(workspaceRoot);
|
|
2265
|
+
const state = result.status === "ok" ? "installed" : result.status;
|
|
2266
|
+
|
|
2267
|
+
if (options.quiet) {
|
|
2268
|
+
console.log(state);
|
|
2269
|
+
setQuietStatusExitCode(state);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
if (state === "missing") {
|
|
2274
|
+
console.log("[warn] 未检测到 Spec 项目资产");
|
|
2275
|
+
console.log(` 工作区: ${workspaceRoot}`);
|
|
2276
|
+
setQuietStatusExitCode("missing");
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
console.log(state === "installed" ? "[ok] Spec 项目资产状态正常" : "[warn] Spec 项目资产存在问题");
|
|
2281
|
+
console.log(` 工作区: ${workspaceRoot}`);
|
|
2282
|
+
console.log(` 任务数: ${result.stats.total}`);
|
|
2283
|
+
console.log(` 进行中: ${result.stats.inProgress}`);
|
|
2284
|
+
for (const issue of result.issues || []) {
|
|
2285
|
+
console.log(` Issue: ${issue}`);
|
|
2286
|
+
}
|
|
2287
|
+
setQuietStatusExitCode(state);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
async function commandSpecEnable(options) {
|
|
1612
2291
|
ensureBundledSpecResources();
|
|
1613
2292
|
const targets = resolveTargetsForSpec(options);
|
|
1614
2293
|
const { statePath, state } = readSpecState();
|
|
1615
2294
|
const timestamp = nowISO().replace(/[:.]/g, "-");
|
|
2295
|
+
const prompter = createConflictPrompter(options);
|
|
1616
2296
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
2297
|
+
try {
|
|
2298
|
+
await ensureSpecAssetsInstalled(state, timestamp, options, prompter);
|
|
2299
|
+
for (const targetName of targets) {
|
|
2300
|
+
await enableSpecTarget(targetName, state, timestamp, options, prompter);
|
|
2301
|
+
}
|
|
2302
|
+
} finally {
|
|
2303
|
+
if (prompter) prompter.close();
|
|
1620
2304
|
}
|
|
1621
2305
|
|
|
1622
2306
|
state.updatedAt = nowISO();
|
|
@@ -1639,6 +2323,7 @@ function commandSpecDisable(options) {
|
|
|
1639
2323
|
if (remainingTargets.length === 0) {
|
|
1640
2324
|
restoreSpecAsset(state.assets.templates, options, "Spec templates");
|
|
1641
2325
|
restoreSpecAsset(state.assets.references, options, "Spec references");
|
|
2326
|
+
restoreSpecAsset(state.assets.profiles, options, "Spec profiles");
|
|
1642
2327
|
state.assets = {};
|
|
1643
2328
|
}
|
|
1644
2329
|
|
|
@@ -1646,6 +2331,14 @@ function commandSpecDisable(options) {
|
|
|
1646
2331
|
if (!options.dryRun) {
|
|
1647
2332
|
if (remainingTargets.length === 0) {
|
|
1648
2333
|
removeSpecStateFile();
|
|
2334
|
+
const specHome = getSpecHomeDir();
|
|
2335
|
+
try {
|
|
2336
|
+
if (fs.existsSync(specHome) && fs.readdirSync(specHome).length === 0) {
|
|
2337
|
+
fs.rmdirSync(specHome);
|
|
2338
|
+
}
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
log(options, `[warn] 无法清理 Spec 根目录: ${err.message}`);
|
|
2341
|
+
}
|
|
1649
2342
|
} else {
|
|
1650
2343
|
writeSpecState(statePath, state);
|
|
1651
2344
|
}
|
|
@@ -1654,29 +2347,110 @@ function commandSpecDisable(options) {
|
|
|
1654
2347
|
log(options, `[ok] Spec Profile 已停用 (Targets: ${targets.join(", ")})`);
|
|
1655
2348
|
}
|
|
1656
2349
|
|
|
1657
|
-
function commandSpec(options) {
|
|
2350
|
+
async function commandSpec(options) {
|
|
1658
2351
|
const subcommand = String(options.subcommand || "status").toLowerCase();
|
|
1659
2352
|
if (subcommand === "status") {
|
|
1660
2353
|
return commandSpecStatus(options);
|
|
1661
2354
|
}
|
|
1662
2355
|
if (subcommand === "enable") {
|
|
1663
|
-
return commandSpecEnable(options);
|
|
2356
|
+
return await commandSpecEnable(options);
|
|
1664
2357
|
}
|
|
1665
2358
|
if (subcommand === "disable") {
|
|
1666
2359
|
return commandSpecDisable(options);
|
|
1667
2360
|
}
|
|
2361
|
+
if (subcommand === "init") {
|
|
2362
|
+
return await commandSpecInit(options);
|
|
2363
|
+
}
|
|
2364
|
+
if (subcommand === "doctor") {
|
|
2365
|
+
return commandSpecDoctor(options);
|
|
2366
|
+
}
|
|
1668
2367
|
throw new Error(`未知 spec 子命令: ${subcommand}`);
|
|
1669
2368
|
}
|
|
1670
2369
|
|
|
2370
|
+
async function initTargets(workspaceRoot, targets, options, prompter) {
|
|
2371
|
+
for (const target of targets) {
|
|
2372
|
+
const runOptions = { ...options };
|
|
2373
|
+
const conflicts = [];
|
|
2374
|
+
|
|
2375
|
+
if (target === "gemini") {
|
|
2376
|
+
const agentDir = path.join(workspaceRoot, ".agent");
|
|
2377
|
+
if (fs.existsSync(agentDir)) {
|
|
2378
|
+
conflicts.push({
|
|
2379
|
+
category: "project:gemini",
|
|
2380
|
+
label: ".agent",
|
|
2381
|
+
path: agentDir,
|
|
2382
|
+
target,
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
if (target === "codex") {
|
|
2388
|
+
const managedDir = path.join(workspaceRoot, ".agents");
|
|
2389
|
+
const legacyDir = path.join(workspaceRoot, ".codex");
|
|
2390
|
+
if (fs.existsSync(managedDir) || fs.existsSync(legacyDir)) {
|
|
2391
|
+
if (fs.existsSync(managedDir)) {
|
|
2392
|
+
conflicts.push({
|
|
2393
|
+
category: "project:codex",
|
|
2394
|
+
label: ".agents",
|
|
2395
|
+
path: managedDir,
|
|
2396
|
+
target,
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
if (fs.existsSync(legacyDir)) {
|
|
2400
|
+
conflicts.push({
|
|
2401
|
+
category: "project:codex",
|
|
2402
|
+
label: ".codex",
|
|
2403
|
+
path: legacyDir,
|
|
2404
|
+
target,
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
if (conflicts.length > 0) {
|
|
2411
|
+
if (!prompter && !runOptions.force) {
|
|
2412
|
+
throw new Error("检测到已有资产且当前环境不可交互,请使用 --force 或在交互终端中重试。");
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
const timestamp = nowISO().replace(/[:.]/g, "-");
|
|
2416
|
+
let shouldSkip = false;
|
|
2417
|
+
|
|
2418
|
+
for (const conflict of conflicts) {
|
|
2419
|
+
const action = prompter ? await prompter.resolveConflict(conflict) : "backup";
|
|
2420
|
+
if (action === "keep") {
|
|
2421
|
+
log(options, `[skip] 已保留现有资产,跳过初始化: ${conflict.label}`);
|
|
2422
|
+
shouldSkip = true;
|
|
2423
|
+
break;
|
|
2424
|
+
}
|
|
2425
|
+
if (action === "backup") {
|
|
2426
|
+
const backupRootName = conflict.label === ".agent" ? ".agent-backup" : ".agents-backup";
|
|
2427
|
+
backupWorkspaceDir(workspaceRoot, conflict.path, backupRootName, timestamp, options, `工作区资产 ${conflict.label}`);
|
|
2428
|
+
}
|
|
2429
|
+
// remove/backup 都需要强制覆盖才能继续
|
|
2430
|
+
runOptions.force = true;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
if (shouldSkip) {
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
const adapter = createAdapter(target, workspaceRoot, runOptions);
|
|
2439
|
+
log(options, `[sync] 正在初始化目标 [${target}] ...`);
|
|
2440
|
+
adapter.install(BUNDLED_AGENT_DIR);
|
|
2441
|
+
registerWorkspaceTarget(workspaceRoot, target, runOptions);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
1671
2445
|
async function commandInit(options) {
|
|
1672
2446
|
const workspaceRoot = resolveWorkspaceRoot(options.path);
|
|
1673
2447
|
const targets = await resolveTargetsForInit(options);
|
|
2448
|
+
const prompter = createConflictPrompter(options);
|
|
1674
2449
|
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
registerWorkspaceTarget(workspaceRoot, target, options);
|
|
2450
|
+
try {
|
|
2451
|
+
await initTargets(workspaceRoot, targets, options, prompter);
|
|
2452
|
+
} finally {
|
|
2453
|
+
if (prompter) prompter.close();
|
|
1680
2454
|
}
|
|
1681
2455
|
|
|
1682
2456
|
if (targets.length > 0) {
|
|
@@ -1684,9 +2458,62 @@ async function commandInit(options) {
|
|
|
1684
2458
|
}
|
|
1685
2459
|
}
|
|
1686
2460
|
|
|
2461
|
+
function evaluateCodexUpdateConflict(activeDir) {
|
|
2462
|
+
const manifestPath = path.join(activeDir, "manifest.json");
|
|
2463
|
+
const details = {
|
|
2464
|
+
hasConflict: false,
|
|
2465
|
+
manifestMissing: false,
|
|
2466
|
+
modifiedCount: 0,
|
|
2467
|
+
unknownCount: 0,
|
|
2468
|
+
};
|
|
2469
|
+
|
|
2470
|
+
if (!fs.existsSync(manifestPath)) {
|
|
2471
|
+
details.hasConflict = true;
|
|
2472
|
+
details.manifestMissing = true;
|
|
2473
|
+
return details;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
try {
|
|
2477
|
+
const manager = new ManifestManager(manifestPath, { target: "codex" });
|
|
2478
|
+
manager.load();
|
|
2479
|
+
const drift = manager.checkDrift(activeDir);
|
|
2480
|
+
if (drift.modified.length > 0) {
|
|
2481
|
+
details.hasConflict = true;
|
|
2482
|
+
details.modifiedCount = drift.modified.length;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
const manifestFiles = manager.manifest.files || {};
|
|
2486
|
+
const stack = [activeDir];
|
|
2487
|
+
while (stack.length > 0) {
|
|
2488
|
+
const dir = stack.pop();
|
|
2489
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2490
|
+
for (const entry of entries) {
|
|
2491
|
+
const fullPath = path.join(dir, entry.name);
|
|
2492
|
+
if (entry.isDirectory()) {
|
|
2493
|
+
stack.push(fullPath);
|
|
2494
|
+
continue;
|
|
2495
|
+
}
|
|
2496
|
+
const relPath = path.relative(activeDir, fullPath).split(path.sep).join("/");
|
|
2497
|
+
if (!manifestFiles[relPath]) {
|
|
2498
|
+
details.unknownCount += 1;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
if (details.unknownCount > 0) {
|
|
2503
|
+
details.hasConflict = true;
|
|
2504
|
+
}
|
|
2505
|
+
} catch (err) {
|
|
2506
|
+
details.hasConflict = true;
|
|
2507
|
+
details.manifestMissing = true;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
return details;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
1687
2513
|
async function commandUpdate(options) {
|
|
1688
2514
|
const workspaceRoot = resolveWorkspaceRoot(options.path);
|
|
1689
2515
|
const targets = resolveTargetsForUpdate(workspaceRoot, options);
|
|
2516
|
+
const prompter = createConflictPrompter(options);
|
|
1690
2517
|
|
|
1691
2518
|
if (targets.length === 0) {
|
|
1692
2519
|
throw new Error(`此目录未检测到 ${PRIMARY_CLI_NAME} 安装,无法更新。请先执行 init。`);
|
|
@@ -1695,7 +2522,8 @@ async function commandUpdate(options) {
|
|
|
1695
2522
|
log(options, `[update] 正在更新 Ling (Targets: ${targets.join(", ")})...`);
|
|
1696
2523
|
|
|
1697
2524
|
let updatedAny = false;
|
|
1698
|
-
|
|
2525
|
+
try {
|
|
2526
|
+
for (const target of targets) {
|
|
1699
2527
|
if (!isTargetInstalled(workspaceRoot, target) && options.targets.length > 0) {
|
|
1700
2528
|
throw new Error(`目标未安装: ${target}`);
|
|
1701
2529
|
}
|
|
@@ -1705,15 +2533,69 @@ async function commandUpdate(options) {
|
|
|
1705
2533
|
}
|
|
1706
2534
|
|
|
1707
2535
|
const runOptions = { ...options, force: true };
|
|
2536
|
+
|
|
2537
|
+
const timestamp = nowISO().replace(/[:.]/g, "-");
|
|
2538
|
+
if (target === "gemini") {
|
|
2539
|
+
const agentDir = path.join(workspaceRoot, ".agent");
|
|
2540
|
+
if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
|
|
2541
|
+
let action = "backup";
|
|
2542
|
+
if (prompter) {
|
|
2543
|
+
action = await prompter.resolveConflict({
|
|
2544
|
+
category: "project:gemini",
|
|
2545
|
+
label: ".agent",
|
|
2546
|
+
path: agentDir,
|
|
2547
|
+
target,
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
if (action === "keep") {
|
|
2551
|
+
log(options, "[skip] 已保留现有资产,跳过更新: .agent");
|
|
2552
|
+
continue;
|
|
2553
|
+
}
|
|
2554
|
+
if (action === "backup") {
|
|
2555
|
+
backupWorkspaceDir(workspaceRoot, agentDir, ".agent-backup", timestamp, options, "工作区资产 .agent");
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
if (target === "codex") {
|
|
2561
|
+
const managedDir = path.join(workspaceRoot, ".agents");
|
|
2562
|
+
const legacyDir = path.join(workspaceRoot, ".codex");
|
|
2563
|
+
const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
|
|
2564
|
+
const conflict = evaluateCodexUpdateConflict(activeDir);
|
|
2565
|
+
if (conflict.hasConflict) {
|
|
2566
|
+
let action = "backup";
|
|
2567
|
+
if (prompter) {
|
|
2568
|
+
action = await prompter.resolveConflict({
|
|
2569
|
+
category: "project:codex",
|
|
2570
|
+
label: fs.existsSync(managedDir) ? ".agents" : ".codex",
|
|
2571
|
+
path: activeDir,
|
|
2572
|
+
target,
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
if (action === "keep") {
|
|
2576
|
+
log(options, `[skip] 已保留现有资产,跳过更新: ${path.basename(activeDir)}`);
|
|
2577
|
+
continue;
|
|
2578
|
+
}
|
|
2579
|
+
if (action === "backup") {
|
|
2580
|
+
const backupRootName = ".agents-backup";
|
|
2581
|
+
backupWorkspaceDir(workspaceRoot, activeDir, backupRootName, timestamp, options, `工作区资产 ${path.basename(activeDir)}`);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
|
|
1708
2586
|
const adapter = createAdapter(target, workspaceRoot, runOptions);
|
|
1709
2587
|
log(options, `[sync] 更新 [${target}] ...`);
|
|
1710
2588
|
adapter.update(BUNDLED_AGENT_DIR);
|
|
1711
2589
|
registerWorkspaceTarget(workspaceRoot, target, runOptions);
|
|
1712
2590
|
updatedAny = true;
|
|
2591
|
+
}
|
|
2592
|
+
} finally {
|
|
2593
|
+
if (prompter) prompter.close();
|
|
1713
2594
|
}
|
|
1714
2595
|
|
|
1715
2596
|
if (!updatedAny) {
|
|
1716
|
-
|
|
2597
|
+
log(options, "[skip] 未执行更新(已保留现有资产)");
|
|
2598
|
+
return;
|
|
1717
2599
|
}
|
|
1718
2600
|
}
|
|
1719
2601
|
|
|
@@ -1754,6 +2636,7 @@ async function commandUpdateAll(options) {
|
|
|
1754
2636
|
|
|
1755
2637
|
log(options, `[update] 开始批量更新工作区(共 ${records.length} 个)...`);
|
|
1756
2638
|
log(options, `[index] 索引文件: ${indexPath}`);
|
|
2639
|
+
const prompter = createConflictPrompter(options);
|
|
1757
2640
|
|
|
1758
2641
|
let updated = 0;
|
|
1759
2642
|
let skipped = 0;
|
|
@@ -1764,7 +2647,8 @@ async function commandUpdateAll(options) {
|
|
|
1764
2647
|
const nextRecords = [];
|
|
1765
2648
|
const removedRecordKeys = new Set();
|
|
1766
2649
|
|
|
1767
|
-
|
|
2650
|
+
try {
|
|
2651
|
+
for (let i = 0; i < records.length; i++) {
|
|
1768
2652
|
const item = normalizeWorkspaceRecordV2(records[i], normalizeAbsolutePath(records[i].path));
|
|
1769
2653
|
const workspacePath = normalizeAbsolutePath(item.path);
|
|
1770
2654
|
const exclusion = evaluateWorkspaceExclusion(index, workspacePath);
|
|
@@ -1825,6 +2709,54 @@ async function commandUpdateAll(options) {
|
|
|
1825
2709
|
path: workspacePath,
|
|
1826
2710
|
silentIndexLog: true,
|
|
1827
2711
|
};
|
|
2712
|
+
|
|
2713
|
+
const timestampForBackup = nowISO().replace(/[:.]/g, "-");
|
|
2714
|
+
|
|
2715
|
+
if (target === "gemini") {
|
|
2716
|
+
const agentDir = path.join(workspacePath, ".agent");
|
|
2717
|
+
if (fs.existsSync(agentDir) && !areDirectoriesEqual(BUNDLED_AGENT_DIR, agentDir)) {
|
|
2718
|
+
let action = "backup";
|
|
2719
|
+
if (prompter) {
|
|
2720
|
+
action = await prompter.resolveConflict({
|
|
2721
|
+
category: "update-all:project:gemini",
|
|
2722
|
+
label: `.agent (${workspacePath})`,
|
|
2723
|
+
path: agentDir,
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
if (action === "keep") {
|
|
2727
|
+
log(options, `[skip] [${i + 1}/${records.length}] 已保留现有资产,跳过更新: ${workspacePath} [gemini]`);
|
|
2728
|
+
continue;
|
|
2729
|
+
}
|
|
2730
|
+
if (action === "backup") {
|
|
2731
|
+
backupWorkspaceDir(workspacePath, agentDir, ".agent-backup", timestampForBackup, options, `工作区资产 .agent (${workspacePath})`);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
if (target === "codex") {
|
|
2737
|
+
const managedDir = path.join(workspacePath, ".agents");
|
|
2738
|
+
const legacyDir = path.join(workspacePath, ".codex");
|
|
2739
|
+
const activeDir = fs.existsSync(managedDir) ? managedDir : legacyDir;
|
|
2740
|
+
const conflict = evaluateCodexUpdateConflict(activeDir);
|
|
2741
|
+
if (conflict.hasConflict) {
|
|
2742
|
+
let action = "backup";
|
|
2743
|
+
if (prompter) {
|
|
2744
|
+
action = await prompter.resolveConflict({
|
|
2745
|
+
category: "update-all:project:codex",
|
|
2746
|
+
label: `${path.basename(activeDir)} (${workspacePath})`,
|
|
2747
|
+
path: activeDir,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
if (action === "keep") {
|
|
2751
|
+
log(options, `[skip] [${i + 1}/${records.length}] 已保留现有资产,跳过更新: ${workspacePath} [codex]`);
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
2754
|
+
if (action === "backup") {
|
|
2755
|
+
backupWorkspaceDir(workspacePath, activeDir, ".agents-backup", timestampForBackup, options, `工作区资产 ${path.basename(activeDir)} (${workspacePath})`);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
1828
2760
|
const adapter = createAdapter(target, workspacePath, runOptions);
|
|
1829
2761
|
adapter.update(BUNDLED_AGENT_DIR);
|
|
1830
2762
|
updatedTargets.push(target);
|
|
@@ -1844,44 +2776,49 @@ async function commandUpdateAll(options) {
|
|
|
1844
2776
|
skipped += 1;
|
|
1845
2777
|
nextRecords.push(item);
|
|
1846
2778
|
}
|
|
1847
|
-
|
|
2779
|
+
}
|
|
1848
2780
|
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
2781
|
+
if (!options.dryRun) {
|
|
2782
|
+
withWorkspaceIndexLock(indexPath, () => {
|
|
2783
|
+
const { index: latestIndex } = readWorkspaceIndex();
|
|
2784
|
+
const mergedMap = new Map();
|
|
1853
2785
|
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
2786
|
+
for (const item of latestIndex.workspaces || []) {
|
|
2787
|
+
if (!item || typeof item.path !== "string") continue;
|
|
2788
|
+
mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
|
|
2789
|
+
}
|
|
1858
2790
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2791
|
+
for (const removedKey of removedRecordKeys) {
|
|
2792
|
+
mergedMap.delete(removedKey);
|
|
2793
|
+
}
|
|
1862
2794
|
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
2795
|
+
for (const item of nextRecords) {
|
|
2796
|
+
if (!item || typeof item.path !== "string") continue;
|
|
2797
|
+
mergedMap.set(pathCompareKey(item.path), normalizeWorkspaceRecordV2(item, normalizeAbsolutePath(item.path)));
|
|
2798
|
+
}
|
|
1867
2799
|
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2800
|
+
latestIndex.workspaces = Array.from(mergedMap.values()).sort((a, b) => a.path.localeCompare(b.path));
|
|
2801
|
+
latestIndex.updatedAt = timestamp;
|
|
2802
|
+
writeWorkspaceIndex(indexPath, latestIndex);
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
1873
2805
|
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2806
|
+
log(options, "[summary] 批量更新完成");
|
|
2807
|
+
log(options, ` 成功: ${updated}`);
|
|
2808
|
+
log(options, ` 跳过: ${skipped}`);
|
|
2809
|
+
log(options, ` 失败: ${failed}`);
|
|
2810
|
+
log(options, ` 清理排除路径: ${removedExcluded}`);
|
|
2811
|
+
if (options.pruneMissing) {
|
|
2812
|
+
log(options, ` 清理失效索引: ${removedMissing}`);
|
|
2813
|
+
}
|
|
1882
2814
|
|
|
1883
|
-
|
|
1884
|
-
|
|
2815
|
+
if (failed > 0) {
|
|
2816
|
+
process.exitCode = 1;
|
|
2817
|
+
}
|
|
2818
|
+
} finally {
|
|
2819
|
+
if (prompter) {
|
|
2820
|
+
prompter.close();
|
|
2821
|
+
}
|
|
1885
2822
|
}
|
|
1886
2823
|
}
|
|
1887
2824
|
|
|
@@ -1946,6 +2883,22 @@ async function commandDoctor(options) {
|
|
|
1946
2883
|
}
|
|
1947
2884
|
}
|
|
1948
2885
|
|
|
2886
|
+
const specResult = checkSpecProjectIntegrity(workspaceRoot);
|
|
2887
|
+
if (specResult.status !== "missing") {
|
|
2888
|
+
out(`\n[SPEC] 检查 Spec 项目资产...`);
|
|
2889
|
+
if (specResult.status === "ok") {
|
|
2890
|
+
out(" [ok] 状态正常");
|
|
2891
|
+
out(` - 任务数: ${specResult.stats.total}, 进行中: ${specResult.stats.inProgress}`);
|
|
2892
|
+
} else {
|
|
2893
|
+
out(` [error] 状态: ${specResult.status}`);
|
|
2894
|
+
out(` - 任务数: ${specResult.stats.total}, 进行中: ${specResult.stats.inProgress}`);
|
|
2895
|
+
for (const issue of specResult.issues || []) {
|
|
2896
|
+
out(` - ${issue}`);
|
|
2897
|
+
}
|
|
2898
|
+
hasIssue = true;
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
|
|
1949
2902
|
if (hasIssue) {
|
|
1950
2903
|
process.exitCode = 1;
|
|
1951
2904
|
}
|
|
@@ -2191,14 +3144,8 @@ function commandStatus(options) {
|
|
|
2191
3144
|
|
|
2192
3145
|
async function main() {
|
|
2193
3146
|
try {
|
|
2194
|
-
const migration = migrateLegacyControlHomeDir();
|
|
2195
3147
|
const { command, options, providedFlags } = parseArgs(process.argv.slice(2));
|
|
2196
3148
|
|
|
2197
|
-
if (migration && !options.quiet) {
|
|
2198
|
-
console.log(`[migrate] 已迁移控制目录: ${migration.from} -> ${migration.to}`);
|
|
2199
|
-
}
|
|
2200
|
-
maybeWarnLegacyCliAlias(options);
|
|
2201
|
-
|
|
2202
3149
|
if (!command || command === "--help" || command === "-h") {
|
|
2203
3150
|
printUsage();
|
|
2204
3151
|
if (!command || command === "--help" || command === "-h") {
|
|
@@ -2240,7 +3187,7 @@ async function main() {
|
|
|
2240
3187
|
}
|
|
2241
3188
|
|
|
2242
3189
|
if (command === "spec") {
|
|
2243
|
-
commandSpec(options);
|
|
3190
|
+
await commandSpec(options);
|
|
2244
3191
|
return;
|
|
2245
3192
|
}
|
|
2246
3193
|
|