abelworkflow 0.1.0 → 0.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/lib/cli.mjs CHANGED
@@ -1,27 +1,62 @@
1
- import { cp, lstat, mkdir, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { cp, link, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import { createInterface } from "node:readline/promises";
4
7
  import { fileURLToPath } from "node:url";
5
8
 
6
9
  const __filename = fileURLToPath(import.meta.url);
7
10
  const packageRoot = dirname(dirname(__filename));
8
11
  const home = homedir();
9
12
  const defaultAgentsDir = join(home, ".agents");
13
+ const installMetadataName = ".abelworkflow-install.json";
14
+ const claudeSettingsPath = join(home, ".claude", "settings.json");
15
+ const claudeVscodeConfigPath = join(home, ".claude", "config.json");
16
+ const claudeMetaConfigPath = join(home, ".claude.json");
17
+ const codexConfigPath = join(home, ".codex", "config.toml");
18
+ const codexAuthPath = join(home, ".codex", "auth.json");
10
19
  const managedEntries = [
11
- "AGENTS.md",
12
- "README.md",
13
- "commands",
14
- "skills",
15
- ".skill-lock.json",
16
- ".gitignore"
20
+ { target: "AGENTS.md" },
21
+ { target: "README.md" },
22
+ { target: "commands", preserveExisting: true },
23
+ { target: "skills", preserveExisting: true, filter: shouldCopySkillPath },
24
+ { target: ".skill-lock.json" },
25
+ { target: ".gitignore", sourceCandidates: [".gitignore", ".npmignore"] }
26
+ ];
27
+ const ignoredSkillPathPatterns = [
28
+ /(^|\/)\.env$/,
29
+ /(^|\/)\.venv(\/|$)/,
30
+ /(^|\/)__pycache__(\/|$)/,
31
+ /(^|\/)node_modules(\/|$)/,
32
+ /(^|\/)tmp(\/|$)/,
33
+ /(^|\/)dist(\/|$)/,
34
+ /(^|\/)build(\/|$)/,
35
+ /^dev-browser\/profiles(\/|$)/,
36
+ /^dev-browser\/tmp(\/|$)/
37
+ ];
38
+ const menuChoices = [
39
+ { value: "full-init", label: "完整初始化:同步工作流 + 可选安装/配置 Claude Code、Codex、技能环境" },
40
+ { value: "install", label: "仅同步/更新工作流到 ~/.agents 并重新链接 Claude/Codex" },
41
+ { value: "grok-search", label: "配置 grok-search 环境变量" },
42
+ { value: "context7", label: "配置 context7-auto-research 环境变量" },
43
+ { value: "prompt-enhancer", label: "配置 prompt-enhancer 环境变量" },
44
+ { value: "claude-install", label: "安装或更新 Claude Code CLI" },
45
+ { value: "claude-api", label: "配置 Claude Code 第三方 API" },
46
+ { value: "codex-install", label: "安装或更新 Codex CLI" },
47
+ { value: "codex-api", label: "配置 Codex 第三方 API" },
48
+ { value: "exit", label: "退出" }
17
49
  ];
18
50
 
19
51
  function parseArgs(argv) {
20
52
  const options = {
21
53
  agentsDir: defaultAgentsDir,
22
54
  force: false,
23
- relinkOnly: false
55
+ relinkOnly: false,
56
+ command: "menu"
24
57
  };
58
+ const positional = [];
59
+ let helpRequested = false;
25
60
 
26
61
  for (let i = 0; i < argv.length; i += 1) {
27
62
  const arg = argv[i];
@@ -42,11 +77,37 @@ function parseArgs(argv) {
42
77
  i += 1;
43
78
  continue;
44
79
  }
45
- if (arg === "--help" || arg === "-h") {
46
- printHelp();
47
- process.exit(0);
80
+ if (arg === "--help" || arg === "-h" || arg === "help") {
81
+ helpRequested = true;
82
+ options.command = "help";
83
+ continue;
84
+ }
85
+ if (arg.startsWith("-")) {
86
+ throw new Error(`Unknown argument: ${arg}`);
48
87
  }
49
- throw new Error(`Unknown argument: ${arg}`);
88
+ positional.push(arg);
89
+ }
90
+
91
+ if (positional.length > 1) {
92
+ throw new Error(`Unknown argument: ${positional.slice(1).join(" ")}`);
93
+ }
94
+
95
+ if (positional[0]) {
96
+ if (["menu", "init"].includes(positional[0])) {
97
+ if (!helpRequested) {
98
+ options.command = "menu";
99
+ }
100
+ } else if (["install", "sync"].includes(positional[0])) {
101
+ if (!helpRequested) {
102
+ options.command = "install";
103
+ }
104
+ } else {
105
+ throw new Error(`Unknown command: ${positional[0]}`);
106
+ }
107
+ }
108
+
109
+ if (options.command === "menu" && (options.force || options.relinkOnly || options.agentsDir !== defaultAgentsDir)) {
110
+ throw new Error("`--force`、`--link-only`、`--agents-dir` 仅能与 `install` 命令一起使用");
50
111
  }
51
112
 
52
113
  return options;
@@ -57,12 +118,44 @@ function printHelp() {
57
118
 
58
119
  Usage:
59
120
  npx abelworkflow
60
- npx abelworkflow --force
61
- npx abelworkflow --link-only
62
- npx abelworkflow --agents-dir /custom/path
121
+ npx abelworkflow init
122
+ npx abelworkflow install
123
+ npx abelworkflow install --force
124
+ npx abelworkflow install --link-only
125
+ npx abelworkflow install --agents-dir /custom/path
126
+
127
+ Default behavior:
128
+ - npx abelworkflow: open the interactive setup menu.
129
+ - npx abelworkflow install: sync managed files and links explicitly.
63
130
  `);
64
131
  }
65
132
 
133
+ function pathToLabel(path) {
134
+ return path.replace(home, "~");
135
+ }
136
+
137
+ function maskSecret(value) {
138
+ if (!value) {
139
+ return "未配置";
140
+ }
141
+ if (value.length <= 4) {
142
+ return "*".repeat(value.length);
143
+ }
144
+ return `${"*".repeat(Math.max(4, value.length - 4))}${value.slice(-4)}`;
145
+ }
146
+
147
+ function escapeRegExp(value) {
148
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
150
+
151
+ function detectLineEnding(content) {
152
+ return content.includes("\r\n") ? "\r\n" : "\n";
153
+ }
154
+
155
+ function collapseBlankLines(content, lineEnding) {
156
+ return content.replace(/(?:\r?\n){3,}/gu, `${lineEnding}${lineEnding}`);
157
+ }
158
+
66
159
  async function pathExists(path) {
67
160
  try {
68
161
  await lstat(path);
@@ -72,6 +165,15 @@ async function pathExists(path) {
72
165
  }
73
166
  }
74
167
 
168
+ async function pathTargetExists(path) {
169
+ try {
170
+ await stat(path);
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
75
177
  async function backupIfNeeded(targetPath, force) {
76
178
  if (!(await pathExists(targetPath))) {
77
179
  return null;
@@ -88,58 +190,346 @@ async function backupIfNeeded(targetPath, force) {
88
190
  async function syncManagedFiles(agentsDir) {
89
191
  await mkdir(agentsDir, { recursive: true });
90
192
 
193
+ const previousMetadata = await readInstallMetadata(agentsDir);
194
+ const managedChildren = {};
195
+
91
196
  for (const entry of managedEntries) {
92
- const source = join(packageRoot, entry);
93
- const target = join(agentsDir, entry);
197
+ const source = await resolveManagedEntrySource(entry);
198
+ const target = join(agentsDir, entry.target);
199
+ if (entry.preserveExisting) {
200
+ managedChildren[entry.target] = await syncPreservedManagedEntry(
201
+ source,
202
+ target,
203
+ entry,
204
+ previousMetadata.managedChildren?.[entry.target] ?? []
205
+ );
206
+ } else {
207
+ await replaceManagedEntry(source, target, entry);
208
+ }
209
+ }
210
+
211
+ return { previousMetadata, managedChildren };
212
+ }
213
+
214
+ async function resolveManagedEntrySource(entry) {
215
+ for (const candidate of entry.sourceCandidates ?? [entry.target]) {
216
+ const source = join(packageRoot, candidate);
217
+ if (await pathExists(source)) {
218
+ return source;
219
+ }
220
+ }
221
+
222
+ const expected = (entry.sourceCandidates ?? [entry.target]).join(", ");
223
+ throw new Error(`Missing managed entry in package: ${expected}`);
224
+ }
225
+
226
+ async function removeIfNotDirectory(path) {
227
+ if (!(await pathExists(path))) {
228
+ return;
229
+ }
230
+
231
+ const entryStat = await lstat(path);
232
+ if (entryStat.isDirectory()) {
233
+ return;
234
+ }
235
+
236
+ try {
237
+ if (entryStat.isSymbolicLink() && (await stat(path)).isDirectory()) {
238
+ return;
239
+ }
240
+ } catch {
241
+ }
242
+
243
+ await rm(path, { recursive: true, force: true });
244
+ }
245
+
246
+ async function replaceManagedEntry(source, target, entry) {
247
+ if (await pathsReferToSameEntry(source, target)) {
248
+ return;
249
+ }
250
+
251
+ const sourceStat = await lstat(source);
252
+ if (sourceStat.isDirectory()) {
94
253
  await rm(target, { recursive: true, force: true });
95
- await cp(source, target, { recursive: true });
96
- }
97
-
98
- await writeFile(
99
- join(agentsDir, ".abelworkflow-install.json"),
100
- JSON.stringify(
101
- {
102
- package: "abelworkflow",
103
- installedAt: new Date().toISOString()
104
- },
105
- null,
106
- 2
107
- ) + "\n",
108
- "utf8"
109
- );
254
+ } else if (await pathExists(target)) {
255
+ const targetStat = await lstat(target);
256
+ if (targetStat.isDirectory()) {
257
+ await rm(target, { recursive: true, force: true });
258
+ }
259
+ }
260
+
261
+ await cp(source, target, {
262
+ recursive: true,
263
+ force: true,
264
+ filter: entry.filter ? (sourcePath) => entry.filter(source, sourcePath) : undefined
265
+ });
266
+ }
267
+
268
+ async function pathsReferToSameEntry(sourcePath, targetPath) {
269
+ if (resolve(sourcePath) === resolve(targetPath)) {
270
+ return true;
271
+ }
272
+
273
+ try {
274
+ const [sourceRealPath, targetRealPath] = await Promise.all([realpath(sourcePath), realpath(targetPath)]);
275
+ return sourceRealPath === targetRealPath;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+
281
+ function shouldCopySkillPath(skillsRoot, sourcePath) {
282
+ const relativePath = relative(skillsRoot, sourcePath);
283
+ if (!relativePath) {
284
+ return true;
285
+ }
286
+
287
+ const normalizedPath = relativePath.replaceAll("\\", "/");
288
+ return !ignoredSkillPathPatterns.some((pattern) => pattern.test(normalizedPath));
289
+ }
290
+
291
+ async function readInstallMetadata(agentsDir) {
292
+ const metadataPath = join(agentsDir, installMetadataName);
293
+ if (!(await pathExists(metadataPath))) {
294
+ return {};
295
+ }
296
+
297
+ try {
298
+ return JSON.parse(await readFile(metadataPath, "utf8"));
299
+ } catch {
300
+ return {};
301
+ }
302
+ }
303
+
304
+ async function writeInstallMetadata(agentsDir, metadata) {
305
+ await writeFile(join(agentsDir, installMetadataName), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
306
+ }
307
+
308
+ async function syncPreservedManagedEntry(sourceRoot, targetRoot, entry, previousManagedChildren) {
309
+ await removeIfNotDirectory(targetRoot);
310
+ await mkdir(targetRoot, { recursive: true });
311
+
312
+ const previousManagedChildSet = new Set(previousManagedChildren);
313
+ const sourceChildren = await getManagedChildNames(sourceRoot, entry.filter);
314
+ const sourceChildSet = new Set(sourceChildren);
315
+ const currentManagedChildren = [];
316
+
317
+ for (const childName of previousManagedChildren) {
318
+ if (!sourceChildSet.has(childName)) {
319
+ await rm(join(targetRoot, childName), { recursive: true, force: true });
320
+ }
321
+ }
322
+
323
+ for (const childName of sourceChildren) {
324
+ if (!(await shouldSyncManagedChild(join(targetRoot, childName), previousManagedChildSet.has(childName)))) {
325
+ continue;
326
+ }
327
+
328
+ await syncManagedSubtree(
329
+ join(sourceRoot, childName),
330
+ join(targetRoot, childName),
331
+ sourceRoot,
332
+ entry.filter
333
+ );
334
+ currentManagedChildren.push(childName);
335
+ }
336
+
337
+ return currentManagedChildren;
338
+ }
339
+
340
+ async function shouldSyncManagedChild(targetPath, wasPreviouslyManaged) {
341
+ if (wasPreviouslyManaged) {
342
+ return true;
343
+ }
344
+
345
+ return !(await pathTargetExists(targetPath));
346
+ }
347
+
348
+ async function getManagedChildNames(sourceRoot, filter) {
349
+ const entries = await readdir(sourceRoot, { withFileTypes: true });
350
+ return entries
351
+ .filter((entry) => !filter || filter(sourceRoot, join(sourceRoot, entry.name)))
352
+ .map((entry) => entry.name);
353
+ }
354
+
355
+ async function syncManagedSubtree(sourcePath, targetPath, managedRoot, filter) {
356
+ if (await pathsReferToSameEntry(sourcePath, targetPath)) {
357
+ return;
358
+ }
359
+
360
+ const sourceStat = await lstat(sourcePath);
361
+ if (!sourceStat.isDirectory()) {
362
+ if (await pathExists(targetPath)) {
363
+ const targetStat = await lstat(targetPath);
364
+ if (targetStat.isDirectory()) {
365
+ await rm(targetPath, { recursive: true, force: true });
366
+ }
367
+ }
368
+
369
+ await cp(sourcePath, targetPath, { recursive: true, force: true });
370
+ return;
371
+ }
372
+
373
+ await removeIfNotDirectory(targetPath);
374
+ await mkdir(targetPath, { recursive: true });
375
+ await pruneMissingManagedPaths(sourcePath, targetPath, managedRoot, filter);
376
+ await cp(sourcePath, targetPath, {
377
+ recursive: true,
378
+ force: true,
379
+ filter: filter ? (candidatePath) => filter(managedRoot, candidatePath) : undefined
380
+ });
381
+ }
382
+
383
+ async function pruneMissingManagedPaths(sourcePath, targetPath, managedRoot, filter) {
384
+ if (!(await pathExists(targetPath))) {
385
+ return;
386
+ }
387
+
388
+ const entries = await readdir(targetPath, { withFileTypes: true });
389
+ for (const entry of entries) {
390
+ const targetEntryPath = join(targetPath, entry.name);
391
+ const sourceEntryPath = join(sourcePath, entry.name);
392
+ if (filter && !filter(managedRoot, sourceEntryPath)) {
393
+ continue;
394
+ }
395
+
396
+ if (!(await pathExists(sourceEntryPath))) {
397
+ await rm(targetEntryPath, { recursive: true, force: true });
398
+ continue;
399
+ }
400
+
401
+ const sourceEntryStat = await lstat(sourceEntryPath);
402
+ if (entry.isDirectory()) {
403
+ if (!sourceEntryStat.isDirectory()) {
404
+ await rm(targetEntryPath, { recursive: true, force: true });
405
+ continue;
406
+ }
407
+
408
+ await pruneMissingManagedPaths(sourceEntryPath, targetEntryPath, managedRoot, filter);
409
+ continue;
410
+ }
411
+
412
+ if (sourceEntryStat.isDirectory()) {
413
+ await rm(targetEntryPath, { recursive: true, force: true });
414
+ }
415
+ }
416
+ }
417
+
418
+ function getPlatform() {
419
+ return process.env.ABELWORKFLOW_TEST_PLATFORM || process.platform;
420
+ }
421
+
422
+ function isWindows() {
423
+ return getPlatform() === "win32";
424
+ }
425
+
426
+ function shouldForceFileSymlinkFailure(kind) {
427
+ return process.env.ABELWORKFLOW_TEST_FORCE_FILE_SYMLINK_EPERM === "1" && isWindows() && kind === "file";
428
+ }
429
+
430
+ function createManagedTargetState(targetPath, sourcePath, kind, mode, status) {
431
+ return { targetPath, sourcePath, kind, mode, status };
110
432
  }
111
433
 
112
- async function ensureSymlink(targetPath, sourcePath, kind, force) {
434
+ async function createSymlink(targetPath, sourcePath, linkType, kind) {
435
+ if (shouldForceFileSymlinkFailure(kind)) {
436
+ const error = new Error("simulated EPERM");
437
+ error.code = "EPERM";
438
+ throw error;
439
+ }
440
+
441
+ await symlink(sourcePath, targetPath, linkType);
442
+ }
443
+
444
+ async function ensureManagedLink(targetPath, sourcePath, kind, force, previousLinkedTargets) {
113
445
  await mkdir(dirname(targetPath), { recursive: true });
446
+ const sourceResolved = resolve(sourcePath);
447
+ const sourceExists = await pathTargetExists(sourcePath);
114
448
 
115
449
  if (await pathExists(targetPath)) {
116
- const stat = await lstat(targetPath);
117
- if (stat.isSymbolicLink()) {
450
+ const targetStat = await lstat(targetPath);
451
+ if (targetStat.isSymbolicLink()) {
118
452
  const existing = await readlink(targetPath);
119
453
  const existingResolved = resolve(dirname(targetPath), existing);
120
- if (existingResolved === resolve(sourcePath)) {
121
- return { targetPath, status: "unchanged" };
454
+ if (existingResolved === sourceResolved) {
455
+ if (!sourceExists) {
456
+ await rm(targetPath, { recursive: true, force: true });
457
+ return { targetPath, status: "removed" };
458
+ }
459
+
460
+ return createManagedTargetState(targetPath, sourcePath, kind, "symlink", "unchanged");
461
+ }
462
+ }
463
+
464
+ const previousState = previousLinkedTargets[targetPath];
465
+ const wasPreviouslyManaged =
466
+ previousState &&
467
+ resolve(previousState.sourcePath) === sourceResolved &&
468
+ previousState.kind === kind;
469
+
470
+ if (!sourceExists) {
471
+ if (wasPreviouslyManaged) {
472
+ await rm(targetPath, { recursive: true, force: true });
473
+ return { targetPath, status: "removed" };
122
474
  }
475
+
476
+ return { targetPath, status: "skipped" };
477
+ }
478
+
479
+ if (wasPreviouslyManaged) {
480
+ await rm(targetPath, { recursive: true, force: true });
481
+ } else {
482
+ await backupIfNeeded(targetPath, force);
123
483
  }
124
- await backupIfNeeded(targetPath, force);
484
+ } else if (!sourceExists) {
485
+ return { targetPath, status: "skipped" };
125
486
  }
126
487
 
127
- const linkType = process.platform === "win32" ? (kind === "dir" ? "junction" : "file") : kind;
128
- await symlink(sourcePath, targetPath, linkType);
129
- return { targetPath, status: "linked" };
488
+ const linkType = isWindows() ? (kind === "dir" ? "junction" : "file") : kind;
489
+
490
+ try {
491
+ await createSymlink(targetPath, sourcePath, linkType, kind);
492
+ return createManagedTargetState(targetPath, sourcePath, kind, "symlink", "linked");
493
+ } catch (error) {
494
+ if (!shouldFallbackToManagedFile(error, kind)) {
495
+ throw error;
496
+ }
497
+ }
498
+
499
+ try {
500
+ await link(sourcePath, targetPath);
501
+ return createManagedTargetState(targetPath, sourcePath, kind, "hardlink", "linked");
502
+ } catch (error) {
503
+ if (!shouldCopyManagedFile(error)) {
504
+ throw error;
505
+ }
506
+ }
507
+
508
+ await cp(sourcePath, targetPath, { recursive: true, force: true });
509
+ return createManagedTargetState(targetPath, sourcePath, kind, "copy", "copied");
130
510
  }
131
511
 
132
- async function linkSkillDirectories(baseDir, agentsDir, force) {
512
+ function shouldFallbackToManagedFile(error, kind) {
513
+ return kind === "file" && isWindows() && ["EPERM", "EACCES"].includes(error?.code);
514
+ }
515
+
516
+ function shouldCopyManagedFile(error) {
517
+ return ["EPERM", "EACCES", "EXDEV", "EINVAL", "UNKNOWN"].includes(error?.code);
518
+ }
519
+
520
+ async function linkSkillDirectories(baseDir, agentsDir, force, previousLinkedTargets) {
133
521
  const results = [];
134
522
  const skillsRoot = join(agentsDir, "skills");
135
- const skillNames = await getDirectoryNames(skillsRoot);
523
+ const skillNames = (await getDirectoryNames(skillsRoot)).filter((skillName) => skillName !== ".system");
524
+ results.push(...(await pruneManagedTargets(join(baseDir, "skills"), skillsRoot, skillNames, previousLinkedTargets)));
136
525
  for (const skillName of skillNames) {
137
526
  results.push(
138
- await ensureSymlink(
527
+ await ensureManagedLink(
139
528
  join(baseDir, "skills", skillName),
140
529
  join(skillsRoot, skillName),
141
530
  "dir",
142
- force
531
+ force,
532
+ previousLinkedTargets
143
533
  )
144
534
  );
145
535
  }
@@ -147,46 +537,201 @@ async function linkSkillDirectories(baseDir, agentsDir, force) {
147
537
  }
148
538
 
149
539
  async function getDirectoryNames(root) {
540
+ if (!(await pathIsDirectory(root))) {
541
+ return [];
542
+ }
543
+
150
544
  const entries = await readdir(root, { withFileTypes: true });
151
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
545
+ const names = await Promise.all(
546
+ entries.map(async (entry) => ((await isDirectoryEntry(root, entry)) ? entry.name : null))
547
+ );
548
+ return names.filter(Boolean);
152
549
  }
153
550
 
154
551
  async function getCommandNames(commandsDir) {
552
+ if (!(await pathIsDirectory(commandsDir))) {
553
+ return [];
554
+ }
555
+
155
556
  const entries = await readdir(commandsDir, { withFileTypes: true });
156
- return entries
157
- .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
158
- .map((entry) => entry.name);
557
+ const names = await Promise.all(
558
+ entries.map(async (entry) => ((await isMarkdownFileEntry(commandsDir, entry)) ? entry.name : null))
559
+ );
560
+ return names.filter(Boolean);
561
+ }
562
+
563
+ async function pathIsDirectory(path) {
564
+ try {
565
+ return (await stat(path)).isDirectory();
566
+ } catch {
567
+ return false;
568
+ }
569
+ }
570
+
571
+ async function pathIsFile(path) {
572
+ try {
573
+ return (await stat(path)).isFile();
574
+ } catch {
575
+ return false;
576
+ }
577
+ }
578
+
579
+ async function isDirectoryEntry(root, entry) {
580
+ if (entry.isDirectory()) {
581
+ return true;
582
+ }
583
+
584
+ if (!entry.isSymbolicLink()) {
585
+ return false;
586
+ }
587
+
588
+ return pathIsDirectory(join(root, entry.name));
589
+ }
590
+
591
+ async function isMarkdownFileEntry(root, entry) {
592
+ if (!entry.name.endsWith(".md")) {
593
+ return false;
594
+ }
595
+
596
+ if (entry.isFile()) {
597
+ return true;
598
+ }
599
+
600
+ if (!entry.isSymbolicLink()) {
601
+ return false;
602
+ }
603
+
604
+ return pathIsFile(join(root, entry.name));
605
+ }
606
+
607
+ async function pruneManagedTargets(targetDir, managedSourceRoot, expectedNames, previousLinkedTargets) {
608
+ if (!(await pathExists(targetDir))) {
609
+ return [];
610
+ }
611
+
612
+ const expectedNameSet = new Set(expectedNames);
613
+ const results = [];
614
+ const entries = await readdir(targetDir, { withFileTypes: true });
615
+
616
+ for (const entry of entries) {
617
+ const targetPath = join(targetDir, entry.name);
618
+ if (entry.isSymbolicLink()) {
619
+ const existing = await readlink(targetPath);
620
+ const existingResolved = resolve(dirname(targetPath), existing);
621
+ if (!isWithinManagedRoot(existingResolved, managedSourceRoot)) {
622
+ continue;
623
+ }
624
+
625
+ if (expectedNameSet.has(entry.name) && (await pathTargetExists(existingResolved))) {
626
+ continue;
627
+ }
628
+
629
+ await rm(targetPath, { recursive: true, force: true });
630
+ results.push({ targetPath, status: "removed" });
631
+ continue;
632
+ }
633
+
634
+ const previousState = previousLinkedTargets[targetPath];
635
+ if (!previousState || !isWithinManagedRoot(resolve(previousState.sourcePath), managedSourceRoot)) {
636
+ continue;
637
+ }
638
+
639
+ if (expectedNameSet.has(entry.name) && (await pathTargetExists(previousState.sourcePath))) {
640
+ continue;
641
+ }
642
+
643
+ await rm(targetPath, { recursive: true, force: true });
644
+ results.push({ targetPath, status: "removed" });
645
+ }
646
+
647
+ return results;
648
+ }
649
+
650
+ function isWithinManagedRoot(targetPath, managedSourceRoot) {
651
+ const relativePath = relative(managedSourceRoot, targetPath);
652
+ if (!relativePath) {
653
+ return false;
654
+ }
655
+
656
+ return relativePath !== ".." && !relativePath.startsWith(`..${isWindows() ? "\\" : "/"}`);
657
+ }
658
+
659
+ function getResultMarker(status) {
660
+ if (status === "unchanged") {
661
+ return "=";
662
+ }
663
+
664
+ if (status === "removed") {
665
+ return "-";
666
+ }
667
+
668
+ return "+";
159
669
  }
160
670
 
161
- async function linkClaude(agentsDir, force) {
671
+ async function linkClaude(agentsDir, force, previousLinkedTargets) {
162
672
  const claudeDir = join(home, ".claude");
673
+ await mkdir(claudeDir, { recursive: true });
674
+ await removeIfNotDirectory(join(claudeDir, "commands"));
675
+ await removeIfNotDirectory(join(claudeDir, "skills"));
163
676
  await mkdir(join(claudeDir, "commands"), { recursive: true });
164
677
  await mkdir(join(claudeDir, "skills"), { recursive: true });
165
678
 
166
679
  return [
167
- await ensureSymlink(join(claudeDir, "CLAUDE.md"), join(agentsDir, "AGENTS.md"), "file", force),
168
- await ensureSymlink(join(claudeDir, "commands", "oc"), join(agentsDir, "commands", "oc"), "dir", force),
169
- ...(await linkSkillDirectories(claudeDir, agentsDir, force))
680
+ await ensureManagedLink(
681
+ join(claudeDir, "CLAUDE.md"),
682
+ join(agentsDir, "AGENTS.md"),
683
+ "file",
684
+ force,
685
+ previousLinkedTargets
686
+ ),
687
+ await ensureManagedLink(
688
+ join(claudeDir, "commands", "oc"),
689
+ join(agentsDir, "commands", "oc"),
690
+ "dir",
691
+ force,
692
+ previousLinkedTargets
693
+ ),
694
+ ...(await linkSkillDirectories(claudeDir, agentsDir, force, previousLinkedTargets))
170
695
  ];
171
696
  }
172
697
 
173
- async function linkCodex(agentsDir, force) {
698
+ async function linkCodex(agentsDir, force, previousLinkedTargets) {
174
699
  const results = [];
175
700
  const codexDir = join(home, ".codex");
701
+ await mkdir(codexDir, { recursive: true });
702
+ await removeIfNotDirectory(join(codexDir, "skills"));
703
+ await removeIfNotDirectory(join(codexDir, "prompts"));
176
704
  await mkdir(join(codexDir, "skills"), { recursive: true });
177
705
  await mkdir(join(codexDir, "prompts"), { recursive: true });
178
706
 
179
- results.push(await ensureSymlink(join(codexDir, "AGENTS.md"), join(agentsDir, "AGENTS.md"), "file", force));
180
- results.push(...(await linkSkillDirectories(codexDir, agentsDir, force)));
707
+ results.push(
708
+ await ensureManagedLink(
709
+ join(codexDir, "AGENTS.md"),
710
+ join(agentsDir, "AGENTS.md"),
711
+ "file",
712
+ force,
713
+ previousLinkedTargets
714
+ )
715
+ );
716
+ results.push(...(await linkSkillDirectories(codexDir, agentsDir, force, previousLinkedTargets)));
181
717
 
182
718
  const commandFiles = await getCommandNames(join(agentsDir, "commands", "oc"));
719
+ results.push(
720
+ ...(await pruneManagedTargets(
721
+ join(codexDir, "prompts"),
722
+ join(agentsDir, "commands", "oc"),
723
+ commandFiles,
724
+ previousLinkedTargets
725
+ ))
726
+ );
183
727
  for (const fileName of commandFiles) {
184
728
  results.push(
185
- await ensureSymlink(
729
+ await ensureManagedLink(
186
730
  join(codexDir, "prompts", fileName),
187
731
  join(agentsDir, "commands", "oc", fileName),
188
732
  "file",
189
- force
733
+ force,
734
+ previousLinkedTargets
190
735
  )
191
736
  );
192
737
  }
@@ -194,29 +739,784 @@ async function linkCodex(agentsDir, force) {
194
739
  return results;
195
740
  }
196
741
 
197
- async function install() {
198
- const options = parseArgs(process.argv.slice(2));
742
+ async function installManagedWorkflow(options) {
743
+ let previousMetadata = {};
744
+ let managedChildren = {};
199
745
 
200
746
  if (!options.relinkOnly) {
201
- await syncManagedFiles(options.agentsDir);
747
+ ({ previousMetadata, managedChildren } = await syncManagedFiles(options.agentsDir));
202
748
  } else if (!(await pathExists(options.agentsDir))) {
203
749
  throw new Error(`${options.agentsDir} does not exist; remove --link-only or install first`);
750
+ } else {
751
+ previousMetadata = await readInstallMetadata(options.agentsDir);
752
+ managedChildren = previousMetadata.managedChildren ?? {};
204
753
  }
205
754
 
206
- const claudeResults = await linkClaude(options.agentsDir, options.force);
207
- const codexResults = await linkCodex(options.agentsDir, options.force);
755
+ const previousLinkedTargets = previousMetadata.linkedTargets ?? {};
756
+ const claudeResults = await linkClaude(options.agentsDir, options.force, previousLinkedTargets);
757
+ const codexResults = await linkCodex(options.agentsDir, options.force, previousLinkedTargets);
758
+ const linkedTargets = Object.fromEntries(
759
+ [...claudeResults, ...codexResults]
760
+ .filter((result) => result.sourcePath)
761
+ .map((result) => [
762
+ result.targetPath,
763
+ {
764
+ sourcePath: result.sourcePath,
765
+ kind: result.kind,
766
+ mode: result.mode
767
+ }
768
+ ])
769
+ );
770
+
771
+ await writeInstallMetadata(options.agentsDir, {
772
+ package: "abelworkflow",
773
+ installedAt: new Date().toISOString(),
774
+ managedChildren,
775
+ linkedTargets
776
+ });
208
777
 
209
778
  console.log(`Installed AbelWorkflow into ${options.agentsDir}`);
210
779
  console.log("");
211
780
  console.log("Linked targets:");
212
781
  for (const result of [...claudeResults, ...codexResults]) {
213
- console.log(`- ${result.status === "unchanged" ? "=" : "+"} ${result.targetPath}`);
782
+ console.log(`- ${getResultMarker(result.status)} ${result.targetPath}`);
214
783
  }
215
784
  console.log("");
216
785
  console.log("Done. Re-run `npx abelworkflow@latest` to update the managed files.");
217
786
  }
218
787
 
219
- install().catch((error) => {
788
+ async function readJsonFileSafe(path, fallback = {}) {
789
+ if (!(await pathExists(path))) {
790
+ return fallback;
791
+ }
792
+
793
+ try {
794
+ return JSON.parse(await readFile(path, "utf8"));
795
+ } catch {
796
+ return fallback;
797
+ }
798
+ }
799
+
800
+ async function writeJsonFileSafe(path, data) {
801
+ await mkdir(dirname(path), { recursive: true });
802
+ await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
803
+ }
804
+
805
+ function parseDotenv(content) {
806
+ const values = {};
807
+ for (const rawLine of content.split(/\r?\n/u)) {
808
+ const line = rawLine.trim();
809
+ if (!line || line.startsWith("#")) {
810
+ continue;
811
+ }
812
+ const separatorIndex = line.indexOf("=");
813
+ if (separatorIndex === -1) {
814
+ continue;
815
+ }
816
+ const key = line.slice(0, separatorIndex).trim();
817
+ let value = line.slice(separatorIndex + 1).trim();
818
+ if (
819
+ (value.startsWith("\"") && value.endsWith("\"")) ||
820
+ (value.startsWith("'") && value.endsWith("'"))
821
+ ) {
822
+ value = value.slice(1, -1);
823
+ }
824
+ if (key) {
825
+ values[key] = value;
826
+ }
827
+ }
828
+ return values;
829
+ }
830
+
831
+ async function readDotenvFile(path) {
832
+ if (!(await pathExists(path))) {
833
+ return {};
834
+ }
835
+
836
+ try {
837
+ return parseDotenv(await readFile(path, "utf8"));
838
+ } catch {
839
+ return {};
840
+ }
841
+ }
842
+
843
+ function quoteEnvValue(value) {
844
+ if (/^[A-Za-z0-9_./:@-]+$/u.test(value)) {
845
+ return value;
846
+ }
847
+ return JSON.stringify(value);
848
+ }
849
+
850
+ function renderDotenv(values) {
851
+ const lines = Object.entries(values)
852
+ .filter(([, value]) => value !== undefined && value !== null && value !== "")
853
+ .sort(([left], [right]) => left.localeCompare(right))
854
+ .map(([key, value]) => `${key}=${quoteEnvValue(String(value))}`);
855
+ return lines.length ? `${lines.join("\n")}\n` : "";
856
+ }
857
+
858
+ async function updateDotenvFile(path, updates) {
859
+ const current = await readDotenvFile(path);
860
+ for (const [key, value] of Object.entries(updates)) {
861
+ if (value === null || value === undefined || value === "") {
862
+ delete current[key];
863
+ } else {
864
+ current[key] = String(value);
865
+ }
866
+ }
867
+ await mkdir(dirname(path), { recursive: true });
868
+ await writeFile(path, renderDotenv(current), "utf8");
869
+ }
870
+
871
+ function currentChoiceIndex(choices, defaultValue) {
872
+ if (defaultValue === undefined) {
873
+ return -1;
874
+ }
875
+ return choices.findIndex((choice) => choice.value === defaultValue);
876
+ }
877
+
878
+ async function setTerminalEcho(enabled) {
879
+ if (!input.isTTY || isWindows()) {
880
+ return;
881
+ }
882
+
883
+ const result = spawnSync("stty", [enabled ? "echo" : "-echo"], { stdio: ["inherit", "ignore", "ignore"] });
884
+ if (result.error) {
885
+ throw result.error;
886
+ }
887
+ }
888
+
889
+ async function promptText(message, options = {}) {
890
+ const { defaultValue, allowEmpty = false } = options;
891
+
892
+ while (true) {
893
+ const suffix = defaultValue !== undefined && defaultValue !== ""
894
+ ? ` [${defaultValue}]`
895
+ : "";
896
+ const rl = createInterface({ input, output });
897
+ let answer;
898
+ try {
899
+ answer = await rl.question(`${message}${suffix}: `);
900
+ } finally {
901
+ rl.close();
902
+ }
903
+
904
+ const value = answer.trim();
905
+ if (!value && defaultValue !== undefined) {
906
+ return defaultValue;
907
+ }
908
+ if (!value && !allowEmpty) {
909
+ console.log("此项不能为空。");
910
+ continue;
911
+ }
912
+ return value;
913
+ }
914
+ }
915
+
916
+ async function promptSecret(message, options = {}) {
917
+ const { defaultValue, allowEmpty = false } = options;
918
+
919
+ if (!input.isTTY || isWindows()) {
920
+ while (true) {
921
+ const suffix = defaultValue !== undefined && defaultValue !== ""
922
+ ? " [直接回车保留现有值]"
923
+ : "";
924
+ const rl = createInterface({ input, output });
925
+ let answer;
926
+ try {
927
+ answer = await rl.question(`${message}${suffix}: `);
928
+ } finally {
929
+ rl.close();
930
+ }
931
+
932
+ const value = answer.trim();
933
+ if (!value && defaultValue !== undefined) {
934
+ return defaultValue;
935
+ }
936
+ if (!value && !allowEmpty) {
937
+ console.log("此项不能为空。");
938
+ continue;
939
+ }
940
+ return value;
941
+ }
942
+ }
943
+
944
+ while (true) {
945
+ const suffix = defaultValue !== undefined && defaultValue !== ""
946
+ ? " [直接回车保留现有值]"
947
+ : "";
948
+ const rl = createInterface({ input, output, terminal: true });
949
+ let answer;
950
+ try {
951
+ await setTerminalEcho(false);
952
+ answer = await rl.question(`${message}${suffix}: `);
953
+ output.write("\n");
954
+ } finally {
955
+ await setTerminalEcho(true);
956
+ rl.close();
957
+ }
958
+
959
+ const value = answer.trim();
960
+ if (!value && defaultValue !== undefined) {
961
+ return defaultValue;
962
+ }
963
+ if (!value && !allowEmpty) {
964
+ console.log("此项不能为空。");
965
+ continue;
966
+ }
967
+ return value;
968
+ }
969
+ }
970
+
971
+ async function promptSelect(message, choices, options = {}) {
972
+ const defaultIndex = currentChoiceIndex(choices, options.defaultValue);
973
+ console.log(`\n${message}`);
974
+ choices.forEach((choice, index) => {
975
+ const defaultMarker = index === defaultIndex ? " [默认]" : "";
976
+ console.log(` ${index + 1}. ${choice.label}${defaultMarker}`);
977
+ });
978
+
979
+ while (true) {
980
+ const fallbackValue = defaultIndex >= 0 ? String(defaultIndex + 1) : undefined;
981
+ const answer = await promptText("请输入序号", { defaultValue: fallbackValue, allowEmpty: defaultIndex >= 0 });
982
+ const index = Number(answer) - 1;
983
+ if (Number.isInteger(index) && index >= 0 && index < choices.length) {
984
+ return choices[index].value;
985
+ }
986
+ const direct = choices.find((choice) => choice.value === answer);
987
+ if (direct) {
988
+ return direct.value;
989
+ }
990
+ console.log("无效选择,请重新输入。");
991
+ }
992
+ }
993
+
994
+ async function promptConfirm(message, defaultValue = true) {
995
+ const value = await promptSelect(message, [
996
+ { value: true, label: "是" },
997
+ { value: false, label: "否" }
998
+ ], { defaultValue });
999
+ return value;
1000
+ }
1001
+
1002
+ function commandExists(command) {
1003
+ const checker = isWindows() ? "where" : "which";
1004
+ const result = spawnSync(checker, [command], { stdio: "ignore" });
1005
+ return result.status === 0;
1006
+ }
1007
+
1008
+ async function runCommand(command, args) {
1009
+ await new Promise((resolvePromise, rejectPromise) => {
1010
+ const child = spawn(command, args, { stdio: "inherit" });
1011
+ child.on("error", rejectPromise);
1012
+ child.on("close", (code) => {
1013
+ if (code === 0) {
1014
+ resolvePromise();
1015
+ return;
1016
+ }
1017
+ rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
1018
+ });
1019
+ });
1020
+ }
1021
+
1022
+ function sanitizeProviderId(name) {
1023
+ return name
1024
+ .trim()
1025
+ .toLowerCase()
1026
+ .replace(/[\s.]+/gu, "-")
1027
+ .replace(/[^a-z0-9_-]/gu, "")
1028
+ .replace(/-+/gu, "-")
1029
+ .replace(/^-|-$/gu, "") || "abelworkflow";
1030
+ }
1031
+
1032
+ async function ensureWorkflowPresent(agentsDir) {
1033
+ if (await pathExists(join(agentsDir, "AGENTS.md"))) {
1034
+ return;
1035
+ }
1036
+
1037
+ console.log("未检测到已安装的 AbelWorkflow,先执行一次工作流同步。");
1038
+ await installManagedWorkflow({
1039
+ agentsDir,
1040
+ force: false,
1041
+ relinkOnly: false
1042
+ });
1043
+ }
1044
+
1045
+ async function configureGrokSearchEnv(agentsDir) {
1046
+ await ensureWorkflowPresent(agentsDir);
1047
+ const envPath = join(agentsDir, "skills", "grok-search", ".env");
1048
+ const existing = await readDotenvFile(envPath);
1049
+ const baseUrl = await promptText("Grok API URL", {
1050
+ defaultValue: existing.GROK_API_URL || "https://api.x.ai/v1"
1051
+ });
1052
+ const apiKey = await promptSecret("Grok API Key", {
1053
+ defaultValue: existing.GROK_API_KEY || undefined
1054
+ });
1055
+ const model = await promptText("Grok 默认模型", {
1056
+ defaultValue: existing.GROK_MODEL || "grok-4-fast"
1057
+ });
1058
+ const useTavily = await promptConfirm("是否同时配置 Tavily 作为额外搜索源?", Boolean(existing.TAVILY_API_KEY));
1059
+ const tavilyKey = useTavily
1060
+ ? await promptSecret("Tavily API Key", { defaultValue: existing.TAVILY_API_KEY || undefined })
1061
+ : "";
1062
+
1063
+ await updateDotenvFile(envPath, {
1064
+ GROK_API_URL: baseUrl,
1065
+ GROK_API_KEY: apiKey,
1066
+ GROK_MODEL: model,
1067
+ TAVILY_API_KEY: useTavily ? tavilyKey : null,
1068
+ TAVILY_ENABLED: useTavily ? "true" : null
1069
+ });
1070
+
1071
+ console.log(`已写入 ${pathToLabel(envPath)}`);
1072
+ }
1073
+
1074
+ async function configureContext7Env(agentsDir) {
1075
+ await ensureWorkflowPresent(agentsDir);
1076
+ const envPath = join(agentsDir, "skills", "context7-auto-research", ".env");
1077
+ const existing = await readDotenvFile(envPath);
1078
+ const apiKey = await promptSecret("Context7 API Key", {
1079
+ defaultValue: existing.CONTEXT7_API_KEY || undefined,
1080
+ allowEmpty: true
1081
+ });
1082
+
1083
+ await updateDotenvFile(envPath, {
1084
+ CONTEXT7_API_KEY: apiKey || null
1085
+ });
1086
+
1087
+ console.log(`已写入 ${pathToLabel(envPath)}`);
1088
+ }
1089
+
1090
+ function resolvePromptEnhancerMode(existing) {
1091
+ if (existing.ANTHROPIC_API_KEY) {
1092
+ return "anthropic";
1093
+ }
1094
+ if (existing.OPENAI_API_KEY) {
1095
+ return "openai";
1096
+ }
1097
+ return "local";
1098
+ }
1099
+
1100
+ async function configurePromptEnhancerEnv(agentsDir) {
1101
+ await ensureWorkflowPresent(agentsDir);
1102
+ const envPath = join(agentsDir, "skills", "prompt-enhancer", ".env");
1103
+ const existing = await readDotenvFile(envPath);
1104
+ const mode = await promptSelect("请选择 prompt-enhancer 使用的提供方", [
1105
+ { value: "anthropic", label: "Anthropic 兼容 Key" },
1106
+ { value: "openai", label: "OpenAI 兼容 Key" },
1107
+ { value: "local", label: "仅保留本地模板兜底,不写 API Key" }
1108
+ ], { defaultValue: resolvePromptEnhancerMode(existing) });
1109
+
1110
+ if (mode === "anthropic") {
1111
+ const apiKey = await promptSecret("ANTHROPIC_API_KEY", {
1112
+ defaultValue: existing.ANTHROPIC_API_KEY || undefined
1113
+ });
1114
+ const model = await promptText("PE_MODEL", {
1115
+ defaultValue: existing.PE_MODEL || "claude-sonnet-4-20250514"
1116
+ });
1117
+
1118
+ await updateDotenvFile(envPath, {
1119
+ ANTHROPIC_API_KEY: apiKey,
1120
+ OPENAI_API_KEY: null,
1121
+ PE_MODEL: model
1122
+ });
1123
+ } else if (mode === "openai") {
1124
+ const apiKey = await promptSecret("OPENAI_API_KEY", {
1125
+ defaultValue: existing.OPENAI_API_KEY || undefined
1126
+ });
1127
+ const model = await promptText("PE_MODEL", {
1128
+ defaultValue: existing.PE_MODEL || "gpt-4o"
1129
+ });
1130
+
1131
+ await updateDotenvFile(envPath, {
1132
+ OPENAI_API_KEY: apiKey,
1133
+ ANTHROPIC_API_KEY: null,
1134
+ PE_MODEL: model
1135
+ });
1136
+ } else {
1137
+ await updateDotenvFile(envPath, {
1138
+ ANTHROPIC_API_KEY: null,
1139
+ OPENAI_API_KEY: null
1140
+ });
1141
+ }
1142
+
1143
+ console.log(`已写入 ${pathToLabel(envPath)}`);
1144
+ }
1145
+
1146
+ function getExistingClaudeApiConfig(settings) {
1147
+ const env = settings?.env || {};
1148
+ return {
1149
+ baseUrl: env.ANTHROPIC_BASE_URL || "https://api.anthropic.com",
1150
+ authType: env.ANTHROPIC_AUTH_TOKEN ? "auth_token" : "api_key",
1151
+ key: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || ""
1152
+ };
1153
+ }
1154
+
1155
+ function ensureApprovedClaudeApiKey(config, apiKey) {
1156
+ if (!apiKey) {
1157
+ return config;
1158
+ }
1159
+
1160
+ const truncated = apiKey.slice(0, 20);
1161
+ if (!config.customApiKeyResponses || typeof config.customApiKeyResponses !== "object") {
1162
+ config.customApiKeyResponses = { approved: [], rejected: [] };
1163
+ }
1164
+ if (!Array.isArray(config.customApiKeyResponses.approved)) {
1165
+ config.customApiKeyResponses.approved = [];
1166
+ }
1167
+ if (!Array.isArray(config.customApiKeyResponses.rejected)) {
1168
+ config.customApiKeyResponses.rejected = [];
1169
+ }
1170
+
1171
+ config.customApiKeyResponses.rejected = config.customApiKeyResponses.rejected.filter((item) => item !== truncated);
1172
+ if (!config.customApiKeyResponses.approved.includes(truncated)) {
1173
+ config.customApiKeyResponses.approved.push(truncated);
1174
+ }
1175
+
1176
+ return config;
1177
+ }
1178
+
1179
+ async function configureClaudeApi() {
1180
+ const settings = await readJsonFileSafe(claudeSettingsPath, {});
1181
+ const existing = getExistingClaudeApiConfig(settings);
1182
+ const authType = await promptSelect("Claude Code 第三方 API 认证方式", [
1183
+ { value: "api_key", label: "API Key" },
1184
+ { value: "auth_token", label: "Auth Token" }
1185
+ ], { defaultValue: existing.authType });
1186
+ const baseUrl = await promptText("Claude Code Base URL", {
1187
+ defaultValue: existing.baseUrl
1188
+ });
1189
+ const key = await promptSecret(authType === "auth_token" ? "Claude Code Auth Token" : "Claude Code API Key", {
1190
+ defaultValue: existing.key || undefined
1191
+ });
1192
+
1193
+ const nextSettings = {
1194
+ ...settings,
1195
+ env: {
1196
+ ...(settings.env || {}),
1197
+ ANTHROPIC_BASE_URL: baseUrl
1198
+ }
1199
+ };
1200
+
1201
+ if (authType === "auth_token") {
1202
+ nextSettings.env.ANTHROPIC_AUTH_TOKEN = key;
1203
+ delete nextSettings.env.ANTHROPIC_API_KEY;
1204
+ } else {
1205
+ nextSettings.env.ANTHROPIC_API_KEY = key;
1206
+ delete nextSettings.env.ANTHROPIC_AUTH_TOKEN;
1207
+ }
1208
+
1209
+ await writeJsonFileSafe(claudeSettingsPath, nextSettings);
1210
+
1211
+ const vscodeConfig = await readJsonFileSafe(claudeVscodeConfigPath, {});
1212
+ vscodeConfig.primaryApiKey = "abelworkflow";
1213
+ await writeJsonFileSafe(claudeVscodeConfigPath, vscodeConfig);
1214
+
1215
+ const metaConfig = await readJsonFileSafe(claudeMetaConfigPath, {});
1216
+ metaConfig.hasCompletedOnboarding = true;
1217
+ ensureApprovedClaudeApiKey(metaConfig, key);
1218
+ await writeJsonFileSafe(claudeMetaConfigPath, metaConfig);
1219
+
1220
+ console.log(`已更新 ${pathToLabel(claudeSettingsPath)} (${authType}, ${baseUrl}, ${maskSecret(key)})`);
1221
+ }
1222
+
1223
+ function updateTopLevelTomlField(content, field, value) {
1224
+ const lineEnding = detectLineEnding(content);
1225
+ const firstSectionMatch = content.match(/^\[/mu);
1226
+ const topLevelEnd = firstSectionMatch?.index ?? content.length;
1227
+ let topLevel = content.slice(0, topLevelEnd);
1228
+ const rest = content.slice(topLevelEnd);
1229
+ const fieldRegex = new RegExp(`^(#\\s*)?${escapeRegExp(field)}\\s*=\\s*["'][^"']*["'][ \\t]*(?:#.*)?\\r?$`, "mu");
1230
+
1231
+ if (value === null) {
1232
+ topLevel = collapseBlankLines(topLevel.replace(fieldRegex, ""), lineEnding);
1233
+ } else {
1234
+ const nextLine = `${field} = ${JSON.stringify(value)}`;
1235
+ if (fieldRegex.test(topLevel)) {
1236
+ topLevel = topLevel.replace(fieldRegex, nextLine);
1237
+ } else {
1238
+ topLevel = topLevel.trimEnd()
1239
+ ? `${topLevel.trimEnd()}${lineEnding}${nextLine}${lineEnding}`
1240
+ : `${nextLine}${lineEnding}`;
1241
+ }
1242
+ }
1243
+
1244
+ topLevel = topLevel.trimEnd();
1245
+
1246
+ if (rest && topLevel) {
1247
+ topLevel = `${topLevel}${lineEnding}${lineEnding}`;
1248
+ }
1249
+
1250
+ return `${topLevel}${rest}`;
1251
+ }
1252
+
1253
+ function removeTomlSection(content, sectionName) {
1254
+ const lineEnding = detectLineEnding(content);
1255
+ const sectionRegex = new RegExp(`(?:\\r?\\n)?\\[${escapeRegExp(sectionName)}\\][\\s\\S]*?(?=\\r?\\n\\[|$)`, "gu");
1256
+ return collapseBlankLines(content.replace(sectionRegex, ""), lineEnding).trimEnd();
1257
+ }
1258
+
1259
+ function buildTomlSection(sectionName, values, lineEnding = "\n") {
1260
+ const lines = [`[${sectionName}]`];
1261
+ for (const [key, value] of Object.entries(values)) {
1262
+ if (value === undefined || value === null || value === "") {
1263
+ continue;
1264
+ }
1265
+ if (typeof value === "string") {
1266
+ lines.push(`${key} = ${JSON.stringify(value)}`);
1267
+ } else if (typeof value === "boolean") {
1268
+ lines.push(`${key} = ${value ? "true" : "false"}`);
1269
+ } else {
1270
+ lines.push(`${key} = ${String(value)}`);
1271
+ }
1272
+ }
1273
+ return `${lines.join(lineEnding)}${lineEnding}`;
1274
+ }
1275
+
1276
+ function readTopLevelTomlString(content, field) {
1277
+ let inSection = false;
1278
+ for (const line of content.split(/\r?\n/u)) {
1279
+ const trimmed = line.trim();
1280
+ if (!trimmed || trimmed.startsWith("#")) {
1281
+ continue;
1282
+ }
1283
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
1284
+ inSection = true;
1285
+ continue;
1286
+ }
1287
+ if (inSection) {
1288
+ continue;
1289
+ }
1290
+ const match = trimmed.match(new RegExp(`^${escapeRegExp(field)}\\s*=\\s*"([^"]+)"$`, "u"));
1291
+ if (match) {
1292
+ return match[1];
1293
+ }
1294
+ }
1295
+ return "";
1296
+ }
1297
+
1298
+ function parseTomlSection(content, sectionName) {
1299
+ const match = content.match(new RegExp(`\\[${escapeRegExp(sectionName)}\\]\\r?\\n([\\s\\S]*?)(?=\\r?\\n\\[|$)`, "u"));
1300
+ if (!match) {
1301
+ return {};
1302
+ }
1303
+
1304
+ const values = {};
1305
+ for (const rawLine of match[1].split(/\r?\n/u)) {
1306
+ const line = rawLine.trim();
1307
+ if (!line || line.startsWith("#")) {
1308
+ continue;
1309
+ }
1310
+ const stringMatch = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"$/u);
1311
+ if (stringMatch) {
1312
+ values[stringMatch[1]] = stringMatch[2];
1313
+ continue;
1314
+ }
1315
+ const boolMatch = line.match(/^([A-Za-z0-9_]+)\s*=\s*(true|false)$/u);
1316
+ if (boolMatch) {
1317
+ values[boolMatch[1]] = boolMatch[2] === "true";
1318
+ }
1319
+ }
1320
+ return values;
1321
+ }
1322
+
1323
+ async function getExistingCodexApiConfig() {
1324
+ const content = await pathExists(codexConfigPath) ? await readFile(codexConfigPath, "utf8") : "";
1325
+ const auth = await readJsonFileSafe(codexAuthPath, {});
1326
+ const providerId = readTopLevelTomlString(content, "model_provider") || "abelworkflow";
1327
+ const model = readTopLevelTomlString(content, "model") || "gpt-5.2";
1328
+ const provider = parseTomlSection(content, `model_providers.${providerId}`);
1329
+ const envKey = provider.temp_env_key || "OPENAI_API_KEY";
1330
+ return {
1331
+ providerId,
1332
+ providerName: provider.name || providerId,
1333
+ baseUrl: provider.base_url || "https://api.openai.com/v1",
1334
+ model: provider.model || model,
1335
+ envKey,
1336
+ apiKey: auth[envKey] || ""
1337
+ };
1338
+ }
1339
+
1340
+ async function configureCodexApi() {
1341
+ const existing = await getExistingCodexApiConfig();
1342
+ const providerName = await promptText("Codex Provider 名称", {
1343
+ defaultValue: existing.providerName || "AbelWorkflow"
1344
+ });
1345
+ const providerId = sanitizeProviderId(providerName);
1346
+ const baseUrl = await promptText("Codex Base URL", {
1347
+ defaultValue: existing.baseUrl
1348
+ });
1349
+ const model = await promptText("Codex 默认模型", {
1350
+ defaultValue: existing.model || "gpt-5.2"
1351
+ });
1352
+ const apiKey = await promptSecret("Codex 第三方 API Key", {
1353
+ defaultValue: existing.apiKey || undefined
1354
+ });
1355
+ const envKey = `${providerId.toUpperCase().replace(/-/gu, "_")}_API_KEY`;
1356
+
1357
+ let content = await pathExists(codexConfigPath) ? await readFile(codexConfigPath, "utf8") : "";
1358
+ const lineEnding = detectLineEnding(content);
1359
+ content = updateTopLevelTomlField(content, "model", model);
1360
+ content = updateTopLevelTomlField(content, "model_provider", providerId);
1361
+ content = removeTomlSection(content, `model_providers.${providerId}`);
1362
+ content = `${content.trimEnd() ? `${content.trimEnd()}${lineEnding}${lineEnding}` : ""}${buildTomlSection(`model_providers.${providerId}`, {
1363
+ name: providerName,
1364
+ base_url: baseUrl,
1365
+ wire_api: "responses",
1366
+ temp_env_key: envKey,
1367
+ requires_openai_auth: true,
1368
+ model
1369
+ }, lineEnding)}`;
1370
+
1371
+ await mkdir(dirname(codexConfigPath), { recursive: true });
1372
+ await writeFile(codexConfigPath, `${content.trim()}${lineEnding}`, "utf8");
1373
+
1374
+ const auth = await readJsonFileSafe(codexAuthPath, {});
1375
+ auth[envKey] = apiKey;
1376
+ await writeJsonFileSafe(codexAuthPath, auth);
1377
+
1378
+ console.log(`已更新 ${pathToLabel(codexConfigPath)} (${providerId}, ${baseUrl}, ${model})`);
1379
+ console.log(`已更新 ${pathToLabel(codexAuthPath)} (${maskSecret(apiKey)})`);
1380
+ }
1381
+
1382
+ async function installCliTool(tool) {
1383
+ const toolConfig = {
1384
+ claude: {
1385
+ label: "Claude Code",
1386
+ command: "claude",
1387
+ packageName: "@anthropic-ai/claude-code"
1388
+ },
1389
+ codex: {
1390
+ label: "Codex",
1391
+ command: "codex",
1392
+ packageName: "@openai/codex"
1393
+ }
1394
+ }[tool];
1395
+
1396
+ if (!toolConfig) {
1397
+ throw new Error(`Unsupported tool: ${tool}`);
1398
+ }
1399
+
1400
+ const installed = commandExists(toolConfig.command);
1401
+ if (installed) {
1402
+ const shouldUpdate = await promptConfirm(`${toolConfig.label} 已检测到,是否继续执行 npm 强制安装/更新?`, false);
1403
+ if (!shouldUpdate) {
1404
+ console.log(`跳过 ${toolConfig.label} 安装。`);
1405
+ return;
1406
+ }
1407
+ }
1408
+
1409
+ console.log(`开始安装 ${toolConfig.label}...`);
1410
+ await runCommand("npm", ["install", "-g", toolConfig.packageName, "--force"]);
1411
+ console.log(`${toolConfig.label} 安装完成。`);
1412
+ }
1413
+
1414
+ async function runFullInit(options) {
1415
+ await installManagedWorkflow({
1416
+ agentsDir: options.agentsDir,
1417
+ force: options.force,
1418
+ relinkOnly: false
1419
+ });
1420
+
1421
+ if (await promptConfirm("是否安装或更新 Claude Code CLI?", false)) {
1422
+ await installCliTool("claude");
1423
+ }
1424
+ if (await promptConfirm("是否配置 Claude Code 第三方 API?", commandExists("claude"))) {
1425
+ await configureClaudeApi();
1426
+ }
1427
+ if (await promptConfirm("是否安装或更新 Codex CLI?", false)) {
1428
+ await installCliTool("codex");
1429
+ }
1430
+ if (await promptConfirm("是否配置 Codex 第三方 API?", commandExists("codex"))) {
1431
+ await configureCodexApi();
1432
+ }
1433
+ if (await promptConfirm("是否填写 grok-search 环境变量?", true)) {
1434
+ await configureGrokSearchEnv(options.agentsDir);
1435
+ }
1436
+ if (await promptConfirm("是否填写 context7-auto-research 环境变量?", true)) {
1437
+ await configureContext7Env(options.agentsDir);
1438
+ }
1439
+ if (await promptConfirm("是否填写 prompt-enhancer 环境变量?", true)) {
1440
+ await configurePromptEnhancerEnv(options.agentsDir);
1441
+ }
1442
+
1443
+ console.log("\nAbelWorkflow 完整初始化完成。");
1444
+ }
1445
+
1446
+ async function runInteractiveMenu(options) {
1447
+ console.log("AbelWorkflow Setup");
1448
+ console.log(`工作流目录: ${pathToLabel(options.agentsDir)}`);
1449
+
1450
+ while (true) {
1451
+ const choice = await promptSelect("请选择操作", menuChoices, { defaultValue: "full-init" });
1452
+
1453
+ if (choice === "exit") {
1454
+ return;
1455
+ }
1456
+
1457
+ if (choice === "full-init") {
1458
+ await runFullInit(options);
1459
+ continue;
1460
+ }
1461
+ if (choice === "install") {
1462
+ await installManagedWorkflow({
1463
+ agentsDir: options.agentsDir,
1464
+ force: options.force,
1465
+ relinkOnly: options.relinkOnly
1466
+ });
1467
+ continue;
1468
+ }
1469
+ if (choice === "grok-search") {
1470
+ await configureGrokSearchEnv(options.agentsDir);
1471
+ continue;
1472
+ }
1473
+ if (choice === "context7") {
1474
+ await configureContext7Env(options.agentsDir);
1475
+ continue;
1476
+ }
1477
+ if (choice === "prompt-enhancer") {
1478
+ await configurePromptEnhancerEnv(options.agentsDir);
1479
+ continue;
1480
+ }
1481
+ if (choice === "claude-install") {
1482
+ await installCliTool("claude");
1483
+ continue;
1484
+ }
1485
+ if (choice === "claude-api") {
1486
+ await configureClaudeApi();
1487
+ continue;
1488
+ }
1489
+ if (choice === "codex-install") {
1490
+ await installCliTool("codex");
1491
+ continue;
1492
+ }
1493
+ if (choice === "codex-api") {
1494
+ await configureCodexApi();
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ async function main() {
1500
+ const options = parseArgs(process.argv.slice(2));
1501
+
1502
+ if (options.command === "help") {
1503
+ printHelp();
1504
+ return;
1505
+ }
1506
+
1507
+ if (options.command === "install") {
1508
+ await installManagedWorkflow(options);
1509
+ return;
1510
+ }
1511
+
1512
+ if (!input.isTTY || !output.isTTY) {
1513
+ throw new Error("交互式菜单需要 TTY 终端;非交互场景请显式使用 `npx abelworkflow install`");
1514
+ }
1515
+
1516
+ await runInteractiveMenu(options);
1517
+ }
1518
+
1519
+ main().catch((error) => {
220
1520
  console.error(error instanceof Error ? error.message : String(error));
221
1521
  process.exit(1);
222
1522
  });