abelworkflow 0.1.0 → 0.2.0

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