@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +759 -2
  4. package/dist/app.d.ts +384 -0
  5. package/dist/app.js +1148 -0
  6. package/dist/cli.d.ts +21 -0
  7. package/dist/cli.js +353 -0
  8. package/dist/config.d.ts +97 -0
  9. package/dist/config.js +171 -0
  10. package/dist/handlers/agentHandlers.d.ts +18 -0
  11. package/dist/handlers/agentHandlers.js +20 -0
  12. package/dist/handlers/assetHandlers.d.ts +29 -0
  13. package/dist/handlers/assetHandlers.js +35 -0
  14. package/dist/handlers/capabilities.d.ts +26 -0
  15. package/dist/handlers/capabilities.js +138 -0
  16. package/dist/handlers/completionHandlers.d.ts +18 -0
  17. package/dist/handlers/completionHandlers.js +13 -0
  18. package/dist/handlers/configHandlers.d.ts +31 -0
  19. package/dist/handlers/configHandlers.js +59 -0
  20. package/dist/handlers/conversationHandlers.d.ts +118 -0
  21. package/dist/handlers/conversationHandlers.js +314 -0
  22. package/dist/handlers/handoffHandlers.d.ts +17 -0
  23. package/dist/handlers/handoffHandlers.js +14 -0
  24. package/dist/handlers/hostHandlers.d.ts +39 -0
  25. package/dist/handlers/hostHandlers.js +56 -0
  26. package/dist/handlers/index.d.ts +128 -0
  27. package/dist/handlers/index.js +92 -0
  28. package/dist/handlers/messageHandlers.d.ts +11 -0
  29. package/dist/handlers/messageHandlers.js +10 -0
  30. package/dist/handlers/modelHandlers.d.ts +17 -0
  31. package/dist/handlers/modelHandlers.js +19 -0
  32. package/dist/handlers/noticeHandlers.d.ts +24 -0
  33. package/dist/handlers/noticeHandlers.js +24 -0
  34. package/dist/handlers/questionHandlers.d.ts +16 -0
  35. package/dist/handlers/questionHandlers.js +16 -0
  36. package/dist/handlers/searchHandlers.d.ts +12 -0
  37. package/dist/handlers/searchHandlers.js +12 -0
  38. package/dist/handlers/workspaceHandlers.d.ts +29 -0
  39. package/dist/handlers/workspaceHandlers.js +47 -0
  40. package/dist/i18n/en.d.ts +243 -0
  41. package/dist/i18n/en.js +270 -0
  42. package/dist/i18n/index.d.ts +43 -0
  43. package/dist/i18n/index.js +72 -0
  44. package/dist/i18n/zh.d.ts +240 -0
  45. package/dist/i18n/zh.js +257 -0
  46. package/dist/index.d.ts +17 -0
  47. package/dist/index.js +15 -0
  48. package/dist/net/UploadReceiver.d.ts +186 -0
  49. package/dist/net/UploadReceiver.js +494 -0
  50. package/dist/net/WsHub.d.ts +231 -0
  51. package/dist/net/WsHub.js +689 -0
  52. package/dist/net/auth.d.ts +73 -0
  53. package/dist/net/auth.js +74 -0
  54. package/dist/net/discovery.d.ts +72 -0
  55. package/dist/net/discovery.js +171 -0
  56. package/dist/net/index.d.ts +14 -0
  57. package/dist/net/index.js +10 -0
  58. package/dist/runtime/ChatRunner.d.ts +374 -0
  59. package/dist/runtime/ChatRunner.js +1204 -0
  60. package/dist/runtime/ConversationDrafts.d.ts +43 -0
  61. package/dist/runtime/ConversationDrafts.js +47 -0
  62. package/dist/runtime/RunRegistry.d.ts +79 -0
  63. package/dist/runtime/RunRegistry.js +108 -0
  64. package/dist/runtime/ScopeRegistry.d.ts +153 -0
  65. package/dist/runtime/ScopeRegistry.js +259 -0
  66. package/dist/runtime/TransientRunner.d.ts +90 -0
  67. package/dist/runtime/TransientRunner.js +179 -0
  68. package/dist/runtime/index.d.ts +14 -0
  69. package/dist/runtime/index.js +10 -0
  70. package/dist/services/AgentService.d.ts +150 -0
  71. package/dist/services/AgentService.js +718 -0
  72. package/dist/services/AssetService.d.ts +338 -0
  73. package/dist/services/AssetService.js +739 -0
  74. package/dist/services/AutoNaming.d.ts +92 -0
  75. package/dist/services/AutoNaming.js +235 -0
  76. package/dist/services/CompletionService.d.ts +122 -0
  77. package/dist/services/CompletionService.js +442 -0
  78. package/dist/services/ConfigService.d.ts +171 -0
  79. package/dist/services/ConfigService.js +695 -0
  80. package/dist/services/ConversationService.d.ts +274 -0
  81. package/dist/services/ConversationService.js +902 -0
  82. package/dist/services/CredentialService.d.ts +33 -0
  83. package/dist/services/CredentialService.js +86 -0
  84. package/dist/services/DiscoveryService.d.ts +182 -0
  85. package/dist/services/DiscoveryService.js +998 -0
  86. package/dist/services/HandoffService.d.ts +189 -0
  87. package/dist/services/HandoffService.js +414 -0
  88. package/dist/services/Logger.d.ts +130 -0
  89. package/dist/services/Logger.js +256 -0
  90. package/dist/services/MessageService.d.ts +88 -0
  91. package/dist/services/MessageService.js +280 -0
  92. package/dist/services/ModelService.d.ts +89 -0
  93. package/dist/services/ModelService.js +489 -0
  94. package/dist/services/NoticeService.d.ts +93 -0
  95. package/dist/services/NoticeService.js +217 -0
  96. package/dist/services/QuestionService.d.ts +184 -0
  97. package/dist/services/QuestionService.js +457 -0
  98. package/dist/services/SearchService.d.ts +54 -0
  99. package/dist/services/SearchService.js +117 -0
  100. package/dist/services/WorkspaceService.d.ts +96 -0
  101. package/dist/services/WorkspaceService.js +213 -0
  102. package/dist/services/imageSize.d.ts +41 -0
  103. package/dist/services/imageSize.js +146 -0
  104. package/dist/services/index.d.ts +20 -0
  105. package/dist/services/index.js +15 -0
  106. package/dist/services/secretMask.d.ts +24 -0
  107. package/dist/services/secretMask.js +34 -0
  108. package/dist/storage/AssetRepository.d.ts +87 -0
  109. package/dist/storage/AssetRepository.js +180 -0
  110. package/dist/storage/ConversationRepository.d.ts +55 -0
  111. package/dist/storage/ConversationRepository.js +128 -0
  112. package/dist/storage/CredentialRepository.d.ts +28 -0
  113. package/dist/storage/CredentialRepository.js +47 -0
  114. package/dist/storage/Database.d.ts +95 -0
  115. package/dist/storage/Database.js +180 -0
  116. package/dist/storage/MessageRepository.d.ts +104 -0
  117. package/dist/storage/MessageRepository.js +323 -0
  118. package/dist/storage/MetaRepository.d.ts +21 -0
  119. package/dist/storage/MetaRepository.js +33 -0
  120. package/dist/storage/NoticeRepository.d.ts +31 -0
  121. package/dist/storage/NoticeRepository.js +89 -0
  122. package/dist/storage/QuestionRepository.d.ts +34 -0
  123. package/dist/storage/QuestionRepository.js +96 -0
  124. package/dist/storage/WorkspaceRepository.d.ts +25 -0
  125. package/dist/storage/WorkspaceRepository.js +44 -0
  126. package/dist/storage/dbWorker.d.ts +10 -0
  127. package/dist/storage/dbWorker.js +132 -0
  128. package/dist/storage/index.d.ts +20 -0
  129. package/dist/storage/index.js +16 -0
  130. package/dist/storage/migrations.d.ts +26 -0
  131. package/dist/storage/migrations.js +207 -0
  132. package/dist/storage/types.d.ts +298 -0
  133. package/dist/storage/types.js +14 -0
  134. package/dist/wire/errors.d.ts +105 -0
  135. package/dist/wire/errors.js +143 -0
  136. package/dist/wire/events.d.ts +102 -0
  137. package/dist/wire/events.js +13 -0
  138. package/dist/wire/frames.d.ts +106 -0
  139. package/dist/wire/frames.js +156 -0
  140. package/dist/wire/index.d.ts +31 -0
  141. package/dist/wire/index.js +31 -0
  142. package/dist/wire/methods.d.ts +1896 -0
  143. package/dist/wire/methods.js +99 -0
  144. package/dist/wire/states.d.ts +487 -0
  145. package/dist/wire/states.js +61 -0
  146. package/package.json +63 -3
@@ -0,0 +1,998 @@
1
+ /**
2
+ * 能力发现服务:把「这个项目现在能用哪些 MCP / Skill / 工具 / 子 Agent」摊成分层清单。
3
+ *
4
+ * 对应能力编号:F-G1(MCP 5 层链,可编辑的只有 2 个作用域)、F-G2(Skill 4 层、工具与子 Agent
5
+ * 各 2 层,只发现真实存在的目录,离项目越近越优先)、F-F6(坏文件要能看见、能修)、
6
+ * L9(MCP 的 5 层链)、R54(5 层链与各能力目录由本服务算好交给 ScopeRegistry)。
7
+ *
8
+ * 三件事分开:
9
+ *
10
+ * 1. **路径**({@link DiscoveryService.scopeAbilityPaths} 等):只有「按 cwd 推出各能力应该去
11
+ * 哪些目录 / 文件找」这一件事,与有没有东西在里面无关。组装层(ScopeRegistry 的
12
+ * `createWorkspaceScopeFactory`)拿的就是这些路径。
13
+ * 2. **读**({@link DiscoveryService.discover}):把每一层的 `missing | ok | broken` 与条目
14
+ * 报出来。`missing` 与 `broken` 必须分开(F-F6):以前两者都当空配置,一保存就把用户的
15
+ * 文件覆盖掉了。
16
+ * 3. **写**({@link DiscoveryService.writeMcpEntry} / {@link DiscoveryService.deleteMcpEntry}):
17
+ * 只写 MCP 的两个可编辑作用域,一次只动一条;写回时保留这一版不认识的字段与 `oauth`
18
+ * (F-F6),其中 `oauth.clientSecret` 省略就按磁盘原文保留(F-F2:读出去的是掩码,
19
+ * 回写掩码就是把掩码当密钥存下来)。
20
+ *
21
+ * 判断「近的优先」用的是层序 `depth`(0 最靠近项目):合并时同 id 取 depth 小的那一层,
22
+ * 被盖住的条目仍然出现在它自己那一层里,只是带上一条「被谁覆盖」的问题说明。
23
+ */
24
+ import { promises as fs } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+ import { discoverMcpServers, discoverSkills, discoverSubAgents, discoverUserToolFiles, } from "@ai-zen/agents-sdk";
28
+ import { DEFAULT_LANGUAGE, render } from "../i18n/index.js";
29
+ import { HostError } from "../wire/errors.js";
30
+ import { isSecretMask, maskSecret } from "./secretMask.js";
31
+ // ---------------------------------------------------------------------------
32
+ // 目录名(与 SDK 的 `CONFIG_SUB_DIRS` 同口径)
33
+ // ---------------------------------------------------------------------------
34
+ /** 数据根下的助手定义目录 */
35
+ const DIR_AGENTS = "agents";
36
+ /** 数据根下的子 Agent 定义目录 */
37
+ const DIR_SUB_AGENTS = "sub-agents";
38
+ /** 数据根下的 Skill 目录 */
39
+ const DIR_SKILLS = "skills";
40
+ /** 数据根下的用户工具目录 */
41
+ const DIR_TOOLS = "tools";
42
+ /** MCP 配置文件名(数据根下与各层同名) */
43
+ const FILE_MCP = "mcp.json";
44
+ /** 项目里 ai-zen 自己的目录(`<cwd>/.ai-zen/`,交接文档也落在这里) */
45
+ const PROJECT_AI_ZEN_DIR = ".ai-zen";
46
+ /** 项目里跨工具通用的目录(`<cwd>/.agents/`) */
47
+ const PROJECT_AGENTS_DIR = ".agents";
48
+ /** 用户主目录下跨工具通用的目录(`~/.agents/`) */
49
+ const HOME_AGENTS_DIR = ".agents";
50
+ /** 一个用户工具文件的两种扩展名(与 SDK 的发现口径一致) */
51
+ const TOOL_FILE_EXTENSIONS = [".js", ".mjs"];
52
+ // ---------------------------------------------------------------------------
53
+ // 文案
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * 一条给人看的事实(`LocalizedText` 的构造口径与素材层一致:码 = 文案键 + 参数 + 当前语言的句子)。
57
+ *
58
+ * 这里用默认语言渲染:发现结果可能在没人连着的时刻被算出(或某条连接拿到的不是自己那条语言),
59
+ * 客户端要按自己的语言显示时用 `messageKey` + `params` 重新渲染。
60
+ */
61
+ function text(messageKey, params = {}) {
62
+ return {
63
+ code: messageKey,
64
+ messageKey,
65
+ params,
66
+ message: render(DEFAULT_LANGUAGE, messageKey, params),
67
+ };
68
+ }
69
+ /** 同上,再挂一份结构化事实(哪个文件、什么原因) */
70
+ function problem(messageKey, params = {}, detail) {
71
+ const base = text(messageKey, params);
72
+ return detail === undefined ? base : { ...base, detail };
73
+ }
74
+ /** 文件系统/SDK 抛上来的东西 → 一句能拼进文案的原因 */
75
+ function reasonOf(error) {
76
+ if (error instanceof Error)
77
+ return error.message;
78
+ if (typeof error === "string")
79
+ return error;
80
+ return "unknown";
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // 服务
84
+ // ---------------------------------------------------------------------------
85
+ export class DiscoveryService {
86
+ configManager;
87
+ findWorkspace;
88
+ homeDir;
89
+ constructor(options) {
90
+ this.configManager = options.configManager;
91
+ this.findWorkspace = options.findWorkspace;
92
+ this.homeDir = options.homeDir ?? homedir();
93
+ }
94
+ /** 数据根(SDK 的 `basePath`:`config.json` 所在目录) */
95
+ get basePath() {
96
+ return this.configManager.basePath;
97
+ }
98
+ /** 助手定义目录(`<basePath>/agents/`,一实体一文件,F-G3) */
99
+ get agentsDir() {
100
+ return join(this.basePath, DIR_AGENTS);
101
+ }
102
+ // -------------------------------------------------------------------------
103
+ // 路径(R54:组装层要的那一份)
104
+ // -------------------------------------------------------------------------
105
+ /**
106
+ * MCP 的 5 层链(L9):`<ws>/.mcp.json` → `<ws>/.ai-zen/mcp.json` → `<ws>/.agents/mcp.json`
107
+ * → `<aiZenDir>/mcp.json` → `~/.agents/mcp.json`。
108
+ *
109
+ * `cwd` 为 `null`(还没打开项目)时只给后两层:项目侧那三层的路径得先有项目才算得出来。
110
+ */
111
+ mcpLayers(cwd) {
112
+ return withoutNulls([
113
+ projectLayer(cwd, 0, `.${FILE_MCP}`, "other"),
114
+ cwd === null
115
+ ? null
116
+ : {
117
+ depth: 1,
118
+ path: join(cwd, PROJECT_AI_ZEN_DIR, FILE_MCP),
119
+ editable: true,
120
+ scope: "workspace",
121
+ owner: "workspace",
122
+ },
123
+ projectLayer(cwd, 2, join(PROJECT_AGENTS_DIR, FILE_MCP), "other"),
124
+ {
125
+ depth: 3,
126
+ path: join(this.basePath, FILE_MCP),
127
+ editable: true,
128
+ scope: "user",
129
+ owner: "user",
130
+ },
131
+ {
132
+ depth: 4,
133
+ path: join(this.homeDir, HOME_AGENTS_DIR, FILE_MCP),
134
+ editable: false,
135
+ scope: null,
136
+ owner: "other",
137
+ },
138
+ ]);
139
+ }
140
+ /** Skill 的 4 层目录:`<ws>/.ai-zen/skills` → `<ws>/.agents/skills` → `<aiZenDir>/skills` → `~/.agents/skills` */
141
+ skillLayers(cwd) {
142
+ return withoutNulls([
143
+ projectLayer(cwd, 0, join(PROJECT_AI_ZEN_DIR, DIR_SKILLS), "workspace"),
144
+ projectLayer(cwd, 1, join(PROJECT_AGENTS_DIR, DIR_SKILLS), "other"),
145
+ {
146
+ depth: 2,
147
+ path: join(this.basePath, DIR_SKILLS),
148
+ editable: false,
149
+ scope: null,
150
+ owner: "user",
151
+ },
152
+ {
153
+ depth: 3,
154
+ path: join(this.homeDir, HOME_AGENTS_DIR, DIR_SKILLS),
155
+ editable: false,
156
+ scope: null,
157
+ owner: "other",
158
+ },
159
+ ]);
160
+ }
161
+ /** 用户工具的 2 层目录:`<ws>/.agents/tools` → `<aiZenDir>/tools` */
162
+ toolLayers(cwd) {
163
+ return withoutNulls([
164
+ projectLayer(cwd, 0, join(PROJECT_AGENTS_DIR, DIR_TOOLS), "other"),
165
+ {
166
+ depth: 1,
167
+ path: join(this.basePath, DIR_TOOLS),
168
+ editable: false,
169
+ scope: null,
170
+ owner: "user",
171
+ },
172
+ ]);
173
+ }
174
+ /** 子 Agent 的 2 层目录:`<ws>/.agents/sub-agents` → `<aiZenDir>/sub-agents` */
175
+ subAgentLayers(cwd) {
176
+ return withoutNulls([
177
+ projectLayer(cwd, 0, join(PROJECT_AGENTS_DIR, DIR_SUB_AGENTS), "other"),
178
+ {
179
+ depth: 1,
180
+ path: join(this.basePath, DIR_SUB_AGENTS),
181
+ editable: false,
182
+ scope: null,
183
+ owner: "user",
184
+ },
185
+ ]);
186
+ }
187
+ /**
188
+ * 组装一个 Scope 要的全部路径(R54)。
189
+ *
190
+ * `agentsDir` 只有一处(数据根下的 `agents/`):助手定义是全局配置,与项目无关。
191
+ * 其余四个按「近的在前」排好,直接喂给 SDK 的 `allInOne`。
192
+ */
193
+ scopeAbilityPaths(cwd) {
194
+ return {
195
+ agentsDir: this.agentsDir,
196
+ skillsPaths: this.skillLayers(cwd).map((layer) => layer.path),
197
+ toolsPaths: this.toolLayers(cwd).map((layer) => layer.path),
198
+ subAgentsPaths: this.subAgentLayers(cwd).map((layer) => layer.path),
199
+ mcpPaths: this.mcpLayers(cwd).map((layer) => layer.path),
200
+ };
201
+ }
202
+ /** 只取 MCP 的 5 层路径(喂给 SDK 的 `discoverMcpServers` / `McpScopePlugin`) */
203
+ mcpPaths(cwd) {
204
+ return this.mcpLayers(cwd).map((layer) => layer.path);
205
+ }
206
+ /** 按作用域找那一层的路径(写入时用) */
207
+ mcpLayer(scope, cwd) {
208
+ const layer = this.mcpLayers(cwd).find((candidate) => candidate.scope === scope);
209
+ if (!layer) {
210
+ throw new HostError("unsupported", {
211
+ params: { what: `the "${scope}" MCP scope` },
212
+ detail: { scope, reason: "no_such_scope" },
213
+ });
214
+ }
215
+ return layer;
216
+ }
217
+ // -------------------------------------------------------------------------
218
+ // 读:一层的状态与条目
219
+ // -------------------------------------------------------------------------
220
+ /**
221
+ * `config.getDiscovery`(F-G1 / F-G2 / F-F6)。
222
+ *
223
+ * 给了 `workspaceId` 就带上项目侧的三层(与项目无关的两层也照常给);没给就只报「用户级」
224
+ * 与「别的工具约定的」那几层 —— 客户端在没打开项目时也能看 MCP 屏。
225
+ */
226
+ async discover(params = {}) {
227
+ const cwd = await this.resolveCwd(params.workspaceId);
228
+ const view = {
229
+ workspaceId: params.workspaceId ?? null,
230
+ mcps: await this.readMcpLayers(cwd),
231
+ skills: await this.readDirectoryLayers("skills", this.skillLayers(cwd)),
232
+ tools: await this.readDirectoryLayers("tools", this.toolLayers(cwd)),
233
+ subagents: await this.readDirectoryLayers("subagents", this.subAgentLayers(cwd)),
234
+ };
235
+ return { discovery: view };
236
+ }
237
+ /** 只读一层(`config.saveMcp` / `config.deleteMcp` 写回之后回报那一层时也用它) */
238
+ async readMcpLayerView(layer) {
239
+ const reading = await this.readMcpFile(layer.path);
240
+ return {
241
+ kind: "mcps",
242
+ depth: layer.depth,
243
+ path: layer.path,
244
+ editable: layer.editable,
245
+ scope: layer.scope,
246
+ state: reading.state,
247
+ problem: reading.problem,
248
+ entries: reading.entries,
249
+ };
250
+ }
251
+ /**
252
+ * 5 层 MCP 链的合并结果(SDK 的 `discoverMcpServers`,先到先得)。
253
+ *
254
+ * 这个方法是「client 侧看到的分层清单」与「Scope 实际连的那些 server」之间的对照点:
255
+ * 两者应当一致,不一致就说明某一层的文件坏了或者被近的层盖住了。
256
+ */
257
+ async effectiveMcpServers(cwd) {
258
+ return await discoverMcpServers(this.mcpPaths(cwd));
259
+ }
260
+ // -------------------------------------------------------------------------
261
+ // 写:MCP 的两个可编辑作用域(一次只动一条)
262
+ // -------------------------------------------------------------------------
263
+ /**
264
+ * 存一条 MCP 条目(F-G1 / F-F6):`entry.id` 在这一层里就改那一条,没有就建。
265
+ *
266
+ * 写回的口径(与 `deleteMcpEntry` 共用同一份合并规则):
267
+ * - 这个 id 的磁盘原文是底,覆盖入参里明确给了的字段,`extra` 最后叠上去 ——
268
+ * 于是这一版不认识的键与没被提到的 `oauth` 都原样留着(F-F6);
269
+ * - `oauth.clientSecret` 跟端点密钥同一套:省略 / 等于当前掩码 → 保留原文,空串才清掉
270
+ * (见 `mergeOauth`);
271
+ * - 层里别的条目一个字节都不动:改一条不会把另一条读不全的字段覆盖掉;
272
+ * - 文件里 `mcpServers` 之外的顶层键也原样带上。
273
+ */
274
+ async writeMcpEntry(scope, entry, cwd) {
275
+ const layer = this.writableMcpLayer(scope, cwd);
276
+ const existing = await this.readRawMcpFile(layer.path);
277
+ const id = entryId(entry);
278
+ const servers = {
279
+ ...existing.servers,
280
+ [id]: applyEntryPatch(existing.servers[id], entry),
281
+ };
282
+ await writeJsonFile(layer.path, { ...existing.rest, mcpServers: servers });
283
+ return await this.readMcpLayerView(layer);
284
+ }
285
+ /**
286
+ * 删一条 MCP 条目(F-F6)。
287
+ *
288
+ * 这个 id 本来就不在这一层里时落盘内容不变,回报 `removed: false`(不是失败)。
289
+ * 文件坏了但用户要清掉一个条目时,`readRawMcpFile` 把读不出来的那份当空 —— 于是这次写入
290
+ * 会用一份合法 JSON 覆盖它,这与「坏文件要能修」(F-F6)是同一个动作。
291
+ */
292
+ async deleteMcpEntry(scope, id, cwd) {
293
+ const key = id.trim();
294
+ if (key === "") {
295
+ throw new HostError("invalid_params", {
296
+ params: { field: "id" },
297
+ detail: { field: "id", reason: "empty" },
298
+ });
299
+ }
300
+ const layer = this.writableMcpLayer(scope, cwd);
301
+ const existing = await this.readRawMcpFile(layer.path);
302
+ const removed = Object.prototype.hasOwnProperty.call(existing.servers, key);
303
+ if (removed) {
304
+ const { [key]: _gone, ...servers } = existing.servers;
305
+ void _gone;
306
+ await writeJsonFile(layer.path, { ...existing.rest, mcpServers: servers });
307
+ }
308
+ return { layer: await this.readMcpLayerView(layer), removed };
309
+ }
310
+ /** 可写作用域 → 那一层;`workspace` 作用域必须能落到某个项目里 */
311
+ writableMcpLayer(scope, cwd) {
312
+ if (scope === "workspace" && (cwd === null || cwd.trim() === "")) {
313
+ throw new HostError("invalid_params", {
314
+ params: { field: "workspaceId" },
315
+ detail: { field: "workspaceId", reason: "workspace_scope_needs_workspace" },
316
+ });
317
+ }
318
+ return this.mcpLayer(scope, cwd);
319
+ }
320
+ // -------------------------------------------------------------------------
321
+ // 内部:MCP
322
+ // -------------------------------------------------------------------------
323
+ async readMcpLayers(cwd) {
324
+ const layers = this.mcpLayers(cwd);
325
+ const views = [];
326
+ // 近的层先读:同一 id 被更近的层定义过时,远的这一条要标上「被谁覆盖」
327
+ const takenBy = new Map();
328
+ for (const layer of layers) {
329
+ const reading = await this.readMcpFile(layer.path);
330
+ const entries = reading.entries.map((entry) => {
331
+ const winner = takenBy.get(entry.id);
332
+ if (winner === undefined) {
333
+ takenBy.set(entry.id, layer.path);
334
+ return entry;
335
+ }
336
+ return {
337
+ ...entry,
338
+ problem: problem("discovery.shadowed", { path: winner }, {
339
+ id: entry.id,
340
+ reason: "overridden",
341
+ by: winner,
342
+ }),
343
+ };
344
+ });
345
+ views.push({
346
+ kind: "mcps",
347
+ depth: layer.depth,
348
+ path: layer.path,
349
+ editable: layer.editable,
350
+ scope: layer.scope,
351
+ state: reading.state,
352
+ problem: reading.problem,
353
+ entries,
354
+ });
355
+ }
356
+ return views;
357
+ }
358
+ /** 读一个 mcp.json:不存在 / 坏了 / 能读 三种结论分开(F-F6) */
359
+ async readMcpFile(path) {
360
+ const raw = await this.readRawMcpFile(path);
361
+ if (raw.state !== "ok")
362
+ return { state: raw.state, problem: raw.problem, entries: [] };
363
+ const entries = [];
364
+ for (const [id, value] of Object.entries(raw.servers)) {
365
+ entries.push(mcpEntryView(id, value, path));
366
+ }
367
+ return { state: "ok", problem: null, entries: sortEntries(entries) };
368
+ }
369
+ /**
370
+ * 读 mcp.json 的原文。
371
+ *
372
+ * `broken` 与 `missing` 必须分开:`missing` 是「这里没有这一层」(正常),`broken` 是
373
+ * 「文件在,但读不成配置」(用户改了、改坏了,界面要能看见并修)。
374
+ */
375
+ async readRawMcpFile(path) {
376
+ let raw;
377
+ try {
378
+ raw = await fs.readFile(path, "utf-8");
379
+ }
380
+ catch (error) {
381
+ if (isNotFound(error)) {
382
+ return { state: "missing", problem: null, servers: {}, rest: {} };
383
+ }
384
+ return {
385
+ state: "broken",
386
+ problem: problem("config.fileBroken", { path, reason: reasonOf(error) }, {
387
+ path,
388
+ reason: "unreadable",
389
+ }),
390
+ servers: {},
391
+ rest: {},
392
+ };
393
+ }
394
+ let parsed;
395
+ try {
396
+ parsed = JSON.parse(raw);
397
+ }
398
+ catch (error) {
399
+ return {
400
+ state: "broken",
401
+ problem: problem("config.fileBroken", { path, reason: reasonOf(error) }, {
402
+ path,
403
+ reason: "invalid_json",
404
+ }),
405
+ servers: {},
406
+ rest: {},
407
+ };
408
+ }
409
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
410
+ return {
411
+ state: "broken",
412
+ problem: problem("config.fileBroken", { path, reason: "not a JSON object" }, {
413
+ path,
414
+ reason: "not_an_object",
415
+ }),
416
+ servers: {},
417
+ rest: {},
418
+ };
419
+ }
420
+ const record = parsed;
421
+ const servers = record.mcpServers;
422
+ if (servers !== undefined && (typeof servers !== "object" || servers === null || Array.isArray(servers))) {
423
+ return {
424
+ state: "broken",
425
+ problem: problem("config.fileBroken", { path, reason: "mcpServers is not an object" }, {
426
+ path,
427
+ reason: "servers_not_an_object",
428
+ }),
429
+ servers: {},
430
+ rest: {},
431
+ };
432
+ }
433
+ const { mcpServers: _ignored, ...rest } = record;
434
+ void _ignored;
435
+ return {
436
+ state: "ok",
437
+ problem: null,
438
+ servers: servers ?? {},
439
+ rest,
440
+ };
441
+ }
442
+ // -------------------------------------------------------------------------
443
+ // 内部:目录类(skills / tools / subagents)
444
+ // -------------------------------------------------------------------------
445
+ async readDirectoryLayers(kind, layers) {
446
+ const views = [];
447
+ const takenBy = new Map();
448
+ for (const layer of layers) {
449
+ const reading = await this.readDirectory(kind, layer.path);
450
+ const entries = reading.entries.map((entry) => {
451
+ const winner = takenBy.get(entry.id);
452
+ if (winner === undefined) {
453
+ takenBy.set(entry.id, layer.path);
454
+ return entry;
455
+ }
456
+ return {
457
+ ...entry,
458
+ problem: problem("discovery.shadowed", { path: winner }, {
459
+ id: entry.id,
460
+ reason: "overridden",
461
+ by: winner,
462
+ }),
463
+ };
464
+ });
465
+ views.push({
466
+ kind,
467
+ depth: layer.depth,
468
+ path: layer.path,
469
+ editable: layer.editable,
470
+ // 目录类能力(Skill / 工具 / 子 Agent)没有「按作用域写入」这回事(F-G2)
471
+ scope: layer.scope,
472
+ state: reading.state,
473
+ problem: reading.problem,
474
+ entries,
475
+ });
476
+ }
477
+ return views;
478
+ }
479
+ /** 按种类读一个目录;目录不存在就是 `missing`,不是目录或读不了就是 `broken` */
480
+ async readDirectory(kind, dir) {
481
+ let info;
482
+ try {
483
+ info = await fs.stat(dir);
484
+ }
485
+ catch (error) {
486
+ if (isNotFound(error))
487
+ return { state: "missing", problem: null, entries: [] };
488
+ return {
489
+ state: "broken",
490
+ problem: problem("config.fileBroken", { path: dir, reason: reasonOf(error) }, {
491
+ path: dir,
492
+ reason: "unreadable",
493
+ }),
494
+ entries: [],
495
+ };
496
+ }
497
+ if (!info.isDirectory()) {
498
+ return {
499
+ state: "broken",
500
+ problem: problem("discovery.notADirectory", { path: dir }, {
501
+ path: dir,
502
+ reason: "not_a_directory",
503
+ }),
504
+ entries: [],
505
+ };
506
+ }
507
+ if (kind === "skills")
508
+ return await this.readSkillLayer(dir);
509
+ if (kind === "tools")
510
+ return await this.readToolLayer(dir);
511
+ return await this.readSubAgentLayer(dir);
512
+ }
513
+ /**
514
+ * 一层的 Skill。
515
+ *
516
+ * 有效条目交给 SDK 的 `discoverSkills`(frontmatter 的解析口径只该有一份);
517
+ * 另外再读一次目录,把「有 SKILL.md 却没被认成 Skill」的那些也报出来 —— 用户手改坏了要看得见。
518
+ */
519
+ async readSkillLayer(dir) {
520
+ let discovered;
521
+ try {
522
+ discovered = await discoverSkills([dir], { silent: true });
523
+ }
524
+ catch (error) {
525
+ return {
526
+ state: "broken",
527
+ problem: problem("config.fileBroken", { path: dir, reason: reasonOf(error) }, {
528
+ path: dir,
529
+ reason: "discovery_failed",
530
+ }),
531
+ entries: [],
532
+ };
533
+ }
534
+ const entries = discovered.map((skill) => directoryEntry({
535
+ id: skill.id,
536
+ name: skill.name,
537
+ description: skill.description === "" ? null : skill.description,
538
+ source: skill.dirPath,
539
+ enabled: true,
540
+ problem: null,
541
+ }));
542
+ const known = new Set(entries.map((entry) => entry.id));
543
+ for (const name of await listSubdirectories(dir)) {
544
+ if (known.has(name))
545
+ continue;
546
+ if (!(await fileExists(join(dir, name, "SKILL.md"))))
547
+ continue;
548
+ entries.push(directoryEntry({
549
+ id: name,
550
+ name,
551
+ description: null,
552
+ source: join(dir, name),
553
+ enabled: false,
554
+ problem: problem("discovery.skillBroken", { name, reason: "no usable frontmatter" }, {
555
+ path: join(dir, name, "SKILL.md"),
556
+ reason: "invalid_frontmatter",
557
+ }),
558
+ }));
559
+ }
560
+ return { state: "ok", problem: null, entries: sortEntries(entries) };
561
+ }
562
+ /**
563
+ * 一层的用户工具。
564
+ *
565
+ * 以 SDK 的 `discoverUserToolFiles` 为「能不能真的变成一个工具」的判据(它自带 import 护栏与
566
+ * 超时);没能加载上的 `.js` / `.mjs` 也列出来并说明原因。
567
+ */
568
+ async readToolLayer(dir) {
569
+ let loaded;
570
+ try {
571
+ loaded = await discoverUserToolFiles([dir], { silent: true, importTimeoutMs: 2_000 });
572
+ }
573
+ catch (error) {
574
+ return {
575
+ state: "broken",
576
+ problem: problem("config.fileBroken", { path: dir, reason: reasonOf(error) }, {
577
+ path: dir,
578
+ reason: "discovery_failed",
579
+ }),
580
+ entries: [],
581
+ };
582
+ }
583
+ const entries = loaded.map((tool) => directoryEntry({
584
+ id: tool.name,
585
+ name: tool.name,
586
+ description: null,
587
+ source: tool.path,
588
+ enabled: true,
589
+ problem: null,
590
+ }));
591
+ const known = new Set(entries.map((entry) => entry.id));
592
+ for (const file of await listFiles(dir)) {
593
+ const extension = TOOL_FILE_EXTENSIONS.find((candidate) => file.endsWith(candidate));
594
+ if (extension === undefined)
595
+ continue;
596
+ const name = file.slice(0, -extension.length);
597
+ if (known.has(name))
598
+ continue;
599
+ entries.push(directoryEntry({
600
+ id: name,
601
+ name,
602
+ description: null,
603
+ source: join(dir, file),
604
+ enabled: false,
605
+ problem: problem("discovery.toolUnloadable", { name, reason: "not a loadable tool" }, {
606
+ path: join(dir, file),
607
+ reason: "load_failed",
608
+ }),
609
+ }));
610
+ }
611
+ return { state: "ok", problem: null, entries: sortEntries(entries) };
612
+ }
613
+ /**
614
+ * 一层的子 Agent。
615
+ *
616
+ * 「什么算子 Agent」由 SDK 的 `discoverSubAgents` 说了算(有 `function` 定义的那些);
617
+ * 这里自己再读一遍文件,是为了拿到每个定义来自哪个文件(`source`)以及读不出来的那几个。
618
+ */
619
+ async readSubAgentLayer(dir) {
620
+ let discovered;
621
+ try {
622
+ discovered = await discoverSubAgents([dir]);
623
+ }
624
+ catch (error) {
625
+ return {
626
+ state: "broken",
627
+ problem: problem("config.fileBroken", { path: dir, reason: reasonOf(error) }, {
628
+ path: dir,
629
+ reason: "discovery_failed",
630
+ }),
631
+ entries: [],
632
+ };
633
+ }
634
+ const entries = [];
635
+ for (const definition of discovered) {
636
+ entries.push(directoryEntry({
637
+ id: definition.id,
638
+ name: definition.name,
639
+ description: definition.function?.description ?? definition.description ?? null,
640
+ source: join(dir, `${definition.id}.json`),
641
+ enabled: true,
642
+ problem: null,
643
+ }));
644
+ }
645
+ const known = new Set(discovered.map((definition) => definition.id));
646
+ for (const file of await listFiles(dir)) {
647
+ if (!file.endsWith(".json"))
648
+ continue;
649
+ const id = file.slice(0, -".json".length);
650
+ // 已经被认成子 Agent 的那些跳过;其余文件要么读不出来,要么不是子 Agent 定义
651
+ if (known.has(id))
652
+ continue;
653
+ const path = join(dir, file);
654
+ const parsed = await readJsonOrNull(path);
655
+ const usable = parsed !== null && typeof parsed === "object" && "function" in parsed;
656
+ if (usable)
657
+ continue;
658
+ entries.push(directoryEntry({
659
+ id,
660
+ name: id,
661
+ description: null,
662
+ source: path,
663
+ enabled: false,
664
+ problem: problem("discovery.subAgentBroken", { name: id, reason: parsed === null ? "not readable as JSON" : "no function definition" }, { path, reason: parsed === null ? "invalid_json" : "missing_function" }),
665
+ }));
666
+ }
667
+ return { state: "ok", problem: null, entries: sortEntries(entries) };
668
+ }
669
+ // -------------------------------------------------------------------------
670
+ // 内部:workspace
671
+ // -------------------------------------------------------------------------
672
+ /** 入参的 workspaceId → cwd;没给就是 `null`(只报与项目无关的那几层) */
673
+ async resolveCwd(workspaceId) {
674
+ if (workspaceId === undefined || workspaceId.trim() === "")
675
+ return null;
676
+ const found = this.findWorkspace ? await this.findWorkspace(workspaceId) : undefined;
677
+ if (!found) {
678
+ throw new HostError("not_found", {
679
+ params: { entity: "workspace" },
680
+ detail: {
681
+ workspaceId,
682
+ reason: this.findWorkspace ? "workspace_not_found" : "workspace_lookup_not_configured",
683
+ },
684
+ });
685
+ }
686
+ return found.cwd;
687
+ }
688
+ }
689
+ // ---------------------------------------------------------------------------
690
+ // MCP 条目:原文 → 视图、入参 → 原文
691
+ // ---------------------------------------------------------------------------
692
+ /** 一条项目侧的层路径;没有 cwd(还没打开项目)时为 `null` */
693
+ function projectLayer(cwd, depth, relative, owner) {
694
+ if (cwd === null)
695
+ return null;
696
+ return {
697
+ depth,
698
+ path: join(cwd, relative),
699
+ editable: false,
700
+ scope: null,
701
+ owner,
702
+ };
703
+ }
704
+ /** 去掉「这个场景下不存在的那几层」 */
705
+ function withoutNulls(layers) {
706
+ return layers.filter((layer) => layer !== null);
707
+ }
708
+ /**
709
+ * 目录类条目(Skill / 工具 / 子 Agent)。
710
+ *
711
+ * 它们的传输、命令、地址这些字段不存在(F-G2 那几类不靠传输启动),`extra` 也没有写回的路。
712
+ * 空值在这里一次填好,免得每个构造点各写一遍。
713
+ */
714
+ function directoryEntry(base) {
715
+ return {
716
+ ...base,
717
+ transport: null,
718
+ command: null,
719
+ args: null,
720
+ env: null,
721
+ url: null,
722
+ headers: null,
723
+ oauth: null,
724
+ extra: {},
725
+ };
726
+ }
727
+ /** 传输方式在磁盘上的几种写法(SDK 优先取 `type`,兼容后两个别名) */
728
+ const TRANSPORT_KEYS = ["type", "transport", "transportType"];
729
+ /**
730
+ * 条目的已知键:不在这份清单里的才进 `extra`。
731
+ *
732
+ * 与入参对齐(`McpServerEntryInput` 的字段 + 传输方式的三个写法);`disabled` 是磁盘上的开关,
733
+ * 视图里折成 `enabled`。
734
+ */
735
+ const KNOWN_ENTRY_KEYS = [
736
+ ...TRANSPORT_KEYS,
737
+ "description",
738
+ "disabled",
739
+ "command",
740
+ "args",
741
+ "env",
742
+ "url",
743
+ "headers",
744
+ "oauth",
745
+ ];
746
+ /**
747
+ * 把一个原始条目变成视图。
748
+ *
749
+ * 已知字段按 SDK 的 `McpServerConfig` 摊开(F-F6:客户端要能看见这条服务器是怎么起的);
750
+ * 这一版不认识的键归 `extra`,写回时逐字带回。
751
+ */
752
+ function mcpEntryView(id, value, source) {
753
+ const raw = asRecord(value);
754
+ if (raw === null) {
755
+ return {
756
+ id,
757
+ name: id,
758
+ description: null,
759
+ source,
760
+ enabled: false,
761
+ problem: problem("discovery.mcpEntryInvalid", { name: id }, {
762
+ path: source,
763
+ reason: "entry_not_an_object",
764
+ }),
765
+ transport: null,
766
+ command: null,
767
+ args: null,
768
+ env: null,
769
+ url: null,
770
+ headers: null,
771
+ oauth: null,
772
+ extra: {},
773
+ };
774
+ }
775
+ const declared = raw.type ?? raw.transport ?? raw.transportType;
776
+ const inferred = typeof raw.command === "string" ? "stdio" : typeof raw.url === "string" ? "http" : null;
777
+ const resolved = typeof declared === "string" ? declared : inferred;
778
+ return {
779
+ id,
780
+ name: id,
781
+ description: typeof raw.description === "string" ? raw.description : null,
782
+ source,
783
+ enabled: raw.disabled !== true,
784
+ problem: resolved === null || !isKnownTransport(resolved)
785
+ ? problem("discovery.mcpEntryInvalid", { name: id }, {
786
+ path: source,
787
+ reason: declared === undefined ? "missing_transport" : "unknown_transport",
788
+ transport: declared,
789
+ })
790
+ : null,
791
+ transport: resolved !== null && isKnownTransport(resolved) ? resolved : null,
792
+ command: typeof raw.command === "string" ? raw.command : null,
793
+ args: stringList(raw.args),
794
+ env: stringMap(raw.env),
795
+ url: typeof raw.url === "string" ? raw.url : null,
796
+ headers: stringMap(raw.headers),
797
+ oauth: oauthOf(raw.oauth),
798
+ extra: extraOf(raw),
799
+ };
800
+ }
801
+ function isKnownTransport(value) {
802
+ return value === "stdio" || value === "http" || value === "sse";
803
+ }
804
+ /** 字符串数组;形状不对按「没有」报,原值仍然留在磁盘上(写回时省略这一项即不动它) */
805
+ function stringList(value) {
806
+ if (!Array.isArray(value))
807
+ return null;
808
+ return value.every((item) => typeof item === "string") ? [...value] : null;
809
+ }
810
+ /** 字符串到字符串的表(`env` / `headers`);形状不对按「没有」报,理由同上 */
811
+ function stringMap(value) {
812
+ const record = asRecord(value);
813
+ if (record === null)
814
+ return null;
815
+ const entries = Object.entries(record);
816
+ if (!entries.every(([, item]) => typeof item === "string"))
817
+ return null;
818
+ return Object.fromEntries(entries);
819
+ }
820
+ /**
821
+ * oauth 的四个必填项 + 两个可选项;形状不对(改坏了)按「没有」报。
822
+ *
823
+ * 密钥在这里就换掩码(F-F2:明文不出网):返回的是 `McpOauthView`,只有
824
+ * `clientSecretMasked` / `clientSecretSet`,明文连视图对象都进不去。
825
+ */
826
+ function oauthOf(value) {
827
+ const record = asRecord(value);
828
+ if (record === null)
829
+ return null;
830
+ const { authorizationUrl, tokenUrl, clientId, clientSecret, scope } = record;
831
+ if (typeof authorizationUrl !== "string")
832
+ return null;
833
+ if (typeof tokenUrl !== "string")
834
+ return null;
835
+ if (typeof clientId !== "string")
836
+ return null;
837
+ const secret = typeof clientSecret === "string" ? clientSecret : "";
838
+ return {
839
+ authorizationUrl,
840
+ tokenUrl,
841
+ clientId,
842
+ clientSecretMasked: maskSecret(secret),
843
+ clientSecretSet: secret.trim() !== "",
844
+ ...(typeof scope === "string" ? { scope } : {}),
845
+ };
846
+ }
847
+ /** 这一版不认识的键(原样,不做归并与解释) */
848
+ function extraOf(raw) {
849
+ const extra = {};
850
+ for (const [key, value] of Object.entries(raw)) {
851
+ if (!KNOWN_ENTRY_KEYS.includes(key))
852
+ extra[key] = value;
853
+ }
854
+ return extra;
855
+ }
856
+ /** 取写入条目的 id 并盯住传输方式(空 id / 不认识的传输方式没地方落地,当场拒) */
857
+ function entryId(entry) {
858
+ const id = typeof entry?.id === "string" ? entry.id.trim() : "";
859
+ if (id === "") {
860
+ throw new HostError("invalid_params", {
861
+ params: { field: "entry.id" },
862
+ detail: { field: "entry.id", reason: "empty" },
863
+ });
864
+ }
865
+ if (typeof entry.transport !== "string" || !isKnownTransport(entry.transport)) {
866
+ throw new HostError("invalid_params", {
867
+ params: { field: "entry.transport" },
868
+ detail: {
869
+ field: "entry.transport",
870
+ reason: "unknown_transport",
871
+ value: entry.transport,
872
+ allowed: ["stdio", "http", "sse"],
873
+ },
874
+ });
875
+ }
876
+ return id;
877
+ }
878
+ /** 传输方式写到哪个键上:沿用磁盘里已有的那个写法,没有就写 `type` */
879
+ function transportKeyOf(previous) {
880
+ return TRANSPORT_KEYS.find((key) => key in previous) ?? "type";
881
+ }
882
+ /**
883
+ * 一条入参 → 磁盘上这个 id 的原文。
884
+ *
885
+ * 以磁盘原文为底:`extra` 先叠上,入参里明确给了的已知字段最后覆盖 —— `undefined` 表示
886
+ * 「这次不改它」,于是磁盘上那个值留着。这样一次只改一条的写入不会把这一版不认识的键、
887
+ * 没被提到的 `oauth`、或客户端看不全的字段弄丢(F-F6)。
888
+ */
889
+ function applyEntryPatch(previous, entry) {
890
+ const base = asRecord(previous) ?? {};
891
+ const next = { ...base, ...(entry.extra ?? {}) };
892
+ next[transportKeyOf(base)] = entry.transport;
893
+ assign(next, "description", entry.description);
894
+ assign(next, "disabled", entry.disabled);
895
+ assign(next, "command", entry.command);
896
+ assign(next, "args", entry.args);
897
+ assign(next, "env", entry.env);
898
+ assign(next, "url", entry.url);
899
+ assign(next, "headers", entry.headers);
900
+ mergeOauth(next, base, entry.oauth);
901
+ return next;
902
+ }
903
+ /**
904
+ * oauth 的合并(F-F2 的密钥口径在 MCP 这边落地的地方)。
905
+ *
906
+ * `oauth` 本身仍是「给了就换」,但里面的 `clientSecret` 跟端点密钥同一套规则:
907
+ * - 省略 → 保留磁盘原值;
908
+ * - 等于当前掩码(客户端把读到的整行原样写回来)→ 同样保留原值;
909
+ * - 空串 → 清掉这一项;
910
+ * - 其余 → 用新值。
911
+ * 于是「客户端把掩码当密钥写回去」不会落盘。
912
+ */
913
+ function mergeOauth(next, base, incoming) {
914
+ if (incoming === undefined)
915
+ return;
916
+ const previous = asRecord(base.oauth);
917
+ const current = typeof previous?.clientSecret === "string" ? previous.clientSecret : "";
918
+ const { clientSecret, ...rest } = incoming;
919
+ const merged = { ...previous, ...rest };
920
+ const secret = clientSecret === undefined || isSecretMask(clientSecret, current)
921
+ ? current
922
+ : clientSecret.trim();
923
+ if (secret !== "")
924
+ merged.clientSecret = secret;
925
+ else
926
+ delete merged.clientSecret;
927
+ next.oauth = merged;
928
+ }
929
+ /** 只在入参真的给了这个字段时覆盖(`undefined` 表示「这次不改它」) */
930
+ function assign(target, key, value) {
931
+ if (value !== undefined)
932
+ target[key] = value;
933
+ }
934
+ function asRecord(value) {
935
+ if (typeof value !== "object" || value === null || Array.isArray(value))
936
+ return null;
937
+ return value;
938
+ }
939
+ // ---------------------------------------------------------------------------
940
+ // 文件系统小工具
941
+ // ---------------------------------------------------------------------------
942
+ /** 原子写一个 JSON 文件(先写 `.tmp` 再 rename,避免留下半截文件) */
943
+ async function writeJsonFile(path, data) {
944
+ await fs.mkdir(dirname(path), { recursive: true });
945
+ const tmp = `${path}.tmp`;
946
+ await fs.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
947
+ await fs.rename(tmp, path);
948
+ }
949
+ /** `ENOENT` 之类的「这里什么都没有」 */
950
+ function isNotFound(error) {
951
+ return typeof error === "object" && error !== null && "code" in error
952
+ ? error.code === "ENOENT" ||
953
+ error.code === "ENOTDIR"
954
+ : false;
955
+ }
956
+ async function fileExists(path) {
957
+ try {
958
+ const info = await fs.stat(path);
959
+ return info.isFile();
960
+ }
961
+ catch {
962
+ return false;
963
+ }
964
+ }
965
+ async function readJsonOrNull(path) {
966
+ try {
967
+ return JSON.parse(await fs.readFile(path, "utf-8"));
968
+ }
969
+ catch {
970
+ return null;
971
+ }
972
+ }
973
+ async function listSubdirectories(dir) {
974
+ try {
975
+ const entries = await fs.readdir(dir, { withFileTypes: true });
976
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
977
+ }
978
+ catch {
979
+ return [];
980
+ }
981
+ }
982
+ async function listFiles(dir) {
983
+ try {
984
+ const entries = await fs.readdir(dir, { withFileTypes: true });
985
+ return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
986
+ }
987
+ catch {
988
+ return [];
989
+ }
990
+ }
991
+ /** 条目排序:能用的在前、同组按名字,保证同一份磁盘状态每次给同一个顺序 */
992
+ function sortEntries(entries) {
993
+ return [...entries].sort((left, right) => {
994
+ if (left.enabled !== right.enabled)
995
+ return left.enabled ? -1 : 1;
996
+ return left.name.localeCompare(right.name);
997
+ });
998
+ }