@humain/terminal 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/bundle/chunks/{anthropic-messages-XULXUBIG.js → anthropic-messages-QFCEKGI3.js} +1 -1
  3. package/dist/bundle/chunks/{azure-openai-responses-JEG2XOYC.js → azure-openai-responses-G4ZMHV37.js} +1 -1
  4. package/dist/bundle/chunks/chunk-7T6XCLJF.js +22 -0
  5. package/dist/bundle/chunks/chunk-D6HHRSB5.js +103 -0
  6. package/dist/bundle/chunks/chunk-EY4DWHQU.js +2 -0
  7. package/dist/bundle/chunks/{chunk-LYAMSPR2.js → chunk-I47ZWR55.js} +483 -506
  8. package/dist/bundle/chunks/{chunk-JUPSJTFQ.js → chunk-K4JAZYQH.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-HPPQHK3J.js → chunk-KKIB2TVR.js} +1 -1
  10. package/dist/bundle/chunks/chunk-LI2YVPWP.js +2 -0
  11. package/dist/bundle/chunks/{chunk-BZQ53VGE.js → chunk-XSR434CO.js} +1 -1
  12. package/dist/bundle/chunks/compat-QCV26HNS.js +2 -0
  13. package/dist/bundle/chunks/dist-PRPIUY3U.js +2 -0
  14. package/dist/bundle/chunks/esm-E7UVEYTO.js +4 -0
  15. package/dist/bundle/chunks/{extract-TRW5FBY6.js → extract-CTIFQZDJ.js} +15 -14
  16. package/dist/bundle/chunks/{google-generative-ai-OX6VSCA6.js → google-generative-ai-RSUU4MWO.js} +1 -1
  17. package/dist/bundle/chunks/{google-vertex-GW76GSS2.js → google-vertex-CKQPOITO.js} +1 -1
  18. package/dist/bundle/chunks/host-WP3MVKS2.js +2 -0
  19. package/dist/bundle/chunks/{node-KOIP55YZ.js → node-NF43OPII.js} +1 -1
  20. package/dist/bundle/chunks/{node-XY3CBY3C.js → node-UMFM7CDZ.js} +1 -1
  21. package/dist/bundle/chunks/{openai-codex-responses-NPLWP6ZC.js → openai-codex-responses-S34V5ZSD.js} +1 -1
  22. package/dist/bundle/chunks/{openai-completions-OT2GTJKC.js → openai-completions-T2IGVUEI.js} +1 -1
  23. package/dist/bundle/chunks/{openai-responses-5A6CR7GI.js → openai-responses-GYZODCT3.js} +1 -1
  24. package/dist/bundle/chunks/readability-LAGF34IB.js +3 -0
  25. package/dist/bundle/chunks/turndown.cjs-HRMZVRUL.js +2 -0
  26. package/dist/bundle/cli.js +1 -1
  27. package/dist/bundle/index.js +1 -1
  28. package/dist/bundle/rpc-entry.js +1 -1
  29. package/dist/core/tools/file-mutation-queue.d.ts.map +1 -1
  30. package/dist/core/tools/file-mutation-queue.js +21 -8
  31. package/dist/core/tools/file-mutation-queue.js.map +1 -1
  32. package/dist/core/tools/subagent-env.d.ts +18 -0
  33. package/dist/core/tools/subagent-env.d.ts.map +1 -0
  34. package/dist/core/tools/subagent-env.js +43 -0
  35. package/dist/core/tools/subagent-env.js.map +1 -0
  36. package/dist/core/tools/subagent-scheduler.d.ts +31 -0
  37. package/dist/core/tools/subagent-scheduler.d.ts.map +1 -0
  38. package/dist/core/tools/subagent-scheduler.js +203 -0
  39. package/dist/core/tools/subagent-scheduler.js.map +1 -0
  40. package/dist/core/tools/subagent.d.ts +83 -3
  41. package/dist/core/tools/subagent.d.ts.map +1 -1
  42. package/dist/core/tools/subagent.js +214 -76
  43. package/dist/core/tools/subagent.js.map +1 -1
  44. package/dist/humain/mcp-adapter/vendor/CHANGELOG.md +78 -0
  45. package/dist/humain/mcp-adapter/vendor/README.md +125 -19
  46. package/dist/humain/mcp-adapter/vendor/agent-plugin-loader.ts +69 -30
  47. package/dist/humain/mcp-adapter/vendor/agent-plugin-provenance.ts +36 -0
  48. package/dist/humain/mcp-adapter/vendor/claude-plugin-loader.ts +365 -0
  49. package/dist/humain/mcp-adapter/vendor/commands.ts +13 -15
  50. package/dist/humain/mcp-adapter/vendor/config.ts +198 -30
  51. package/dist/humain/mcp-adapter/vendor/consent-manager.ts +40 -9
  52. package/dist/humain/mcp-adapter/vendor/direct-tool-surface.ts +250 -0
  53. package/dist/humain/mcp-adapter/vendor/direct-tools.ts +9 -228
  54. package/dist/humain/mcp-adapter/vendor/dist/agent-plugin-loader.js +74 -32
  55. package/dist/humain/mcp-adapter/vendor/dist/agent-plugin-loader.js.map +1 -1
  56. package/dist/humain/mcp-adapter/vendor/dist/agent-plugin-provenance.d.ts +1 -0
  57. package/dist/humain/mcp-adapter/vendor/dist/agent-plugin-provenance.js +31 -0
  58. package/dist/humain/mcp-adapter/vendor/dist/agent-plugin-provenance.js.map +1 -0
  59. package/dist/humain/mcp-adapter/vendor/dist/claude-plugin-loader.d.ts +9 -0
  60. package/dist/humain/mcp-adapter/vendor/dist/claude-plugin-loader.js +362 -0
  61. package/dist/humain/mcp-adapter/vendor/dist/claude-plugin-loader.js.map +1 -0
  62. package/dist/humain/mcp-adapter/vendor/dist/config.d.ts +3 -1
  63. package/dist/humain/mcp-adapter/vendor/dist/config.js +189 -27
  64. package/dist/humain/mcp-adapter/vendor/dist/config.js.map +1 -1
  65. package/dist/humain/mcp-adapter/vendor/dist/metadata-cache.js +7 -16
  66. package/dist/humain/mcp-adapter/vendor/dist/metadata-cache.js.map +1 -1
  67. package/dist/humain/mcp-adapter/vendor/dist/package-mcp-loader.js +5 -17
  68. package/dist/humain/mcp-adapter/vendor/dist/package-mcp-loader.js.map +1 -1
  69. package/dist/humain/mcp-adapter/vendor/dist/types.d.ts +30 -4
  70. package/dist/humain/mcp-adapter/vendor/dist/types.js +26 -0
  71. package/dist/humain/mcp-adapter/vendor/dist/types.js.map +1 -1
  72. package/dist/humain/mcp-adapter/vendor/dist/utils.d.ts +6 -0
  73. package/dist/humain/mcp-adapter/vendor/dist/utils.js +65 -6
  74. package/dist/humain/mcp-adapter/vendor/dist/utils.js.map +1 -1
  75. package/dist/humain/mcp-adapter/vendor/host-html-template.ts +20 -19
  76. package/dist/humain/mcp-adapter/vendor/http-ca.ts +84 -0
  77. package/dist/humain/mcp-adapter/vendor/index.bundle.mjs +37102 -15103
  78. package/dist/humain/mcp-adapter/vendor/index.ts +828 -164
  79. package/dist/humain/mcp-adapter/vendor/init.ts +48 -10
  80. package/dist/humain/mcp-adapter/vendor/lazy-loader.ts +7 -0
  81. package/dist/humain/mcp-adapter/vendor/mcp-auth-fetch.ts +113 -0
  82. package/dist/humain/mcp-adapter/vendor/mcp-auth-flow.ts +256 -77
  83. package/dist/humain/mcp-adapter/vendor/mcp-auth.ts +316 -54
  84. package/dist/humain/mcp-adapter/vendor/mcp-code.ts +45 -18
  85. package/dist/humain/mcp-adapter/vendor/mcp-install.ts +60 -0
  86. package/dist/humain/mcp-adapter/vendor/mcp-oauth-provider.ts +70 -6
  87. package/dist/humain/mcp-adapter/vendor/mcp-output-guard.ts +53 -42
  88. package/dist/humain/mcp-adapter/vendor/mcp-panel-theme.ts +105 -0
  89. package/dist/humain/mcp-adapter/vendor/mcp-panel.ts +366 -331
  90. package/dist/humain/mcp-adapter/vendor/mcp-references.ts +9 -14
  91. package/dist/humain/mcp-adapter/vendor/mcp-script-worker.mjs +3 -1
  92. package/dist/humain/mcp-adapter/vendor/mcp-setup-panel.ts +383 -341
  93. package/dist/humain/mcp-adapter/vendor/mcp-status.ts +5 -0
  94. package/dist/humain/mcp-adapter/vendor/metadata-cache.ts +7 -16
  95. package/dist/humain/mcp-adapter/vendor/namespace-tools.ts +61 -3
  96. package/dist/humain/mcp-adapter/vendor/oauth.ts +6 -2
  97. package/dist/humain/mcp-adapter/vendor/package-mcp-loader.ts +5 -17
  98. package/dist/humain/mcp-adapter/vendor/package.json +13 -3
  99. package/dist/humain/mcp-adapter/vendor/prompts.ts +35 -3
  100. package/dist/humain/mcp-adapter/vendor/proxy-modes.ts +108 -7
  101. package/dist/humain/mcp-adapter/vendor/runtime-owner.ts +16 -1
  102. package/dist/humain/mcp-adapter/vendor/sampling-handler.ts +9 -32
  103. package/dist/humain/mcp-adapter/vendor/sandbox-proxy-template.ts +20 -93
  104. package/dist/humain/mcp-adapter/vendor/server-manager.ts +227 -40
  105. package/dist/humain/mcp-adapter/vendor/session-approvals.ts +187 -0
  106. package/dist/humain/mcp-adapter/vendor/skills/mcp-scripting/SKILL.md +9 -1
  107. package/dist/humain/mcp-adapter/vendor/state.ts +10 -1
  108. package/dist/humain/mcp-adapter/vendor/tool-approval.ts +10 -22
  109. package/dist/humain/mcp-adapter/vendor/tool-metadata.ts +14 -0
  110. package/dist/humain/mcp-adapter/vendor/types.ts +57 -4
  111. package/dist/humain/mcp-adapter/vendor/ui-server.ts +36 -28
  112. package/dist/humain/mcp-adapter/vendor/utils.ts +64 -6
  113. package/dist/humain/web-access/vendor/abortable.d.ts +3 -0
  114. package/dist/humain/web-access/vendor/abortable.d.ts.map +1 -0
  115. package/dist/humain/web-access/vendor/abortable.js +22 -0
  116. package/dist/humain/web-access/vendor/abortable.js.map +1 -0
  117. package/dist/humain/web-access/vendor/crawl4ai.d.ts +9 -0
  118. package/dist/humain/web-access/vendor/crawl4ai.d.ts.map +1 -0
  119. package/dist/humain/web-access/vendor/crawl4ai.js +187 -0
  120. package/dist/humain/web-access/vendor/crawl4ai.js.map +1 -0
  121. package/dist/humain/web-access/vendor/credential-source.d.ts.map +1 -1
  122. package/dist/humain/web-access/vendor/credential-source.js +1 -0
  123. package/dist/humain/web-access/vendor/credential-source.js.map +1 -1
  124. package/dist/humain/web-access/vendor/curator-page.d.ts.map +1 -1
  125. package/dist/humain/web-access/vendor/curator-page.js +20 -2
  126. package/dist/humain/web-access/vendor/curator-page.js.map +1 -1
  127. package/dist/humain/web-access/vendor/curator-run.d.ts +11 -0
  128. package/dist/humain/web-access/vendor/curator-run.d.ts.map +1 -0
  129. package/dist/humain/web-access/vendor/curator-run.js +38 -0
  130. package/dist/humain/web-access/vendor/curator-run.js.map +1 -0
  131. package/dist/humain/web-access/vendor/curator-server.d.ts +1 -0
  132. package/dist/humain/web-access/vendor/curator-server.d.ts.map +1 -1
  133. package/dist/humain/web-access/vendor/curator-server.js +2 -0
  134. package/dist/humain/web-access/vendor/curator-server.js.map +1 -1
  135. package/dist/humain/web-access/vendor/duckduckgo.d.ts.map +1 -1
  136. package/dist/humain/web-access/vendor/duckduckgo.js +1 -1
  137. package/dist/humain/web-access/vendor/duckduckgo.js.map +1 -1
  138. package/dist/humain/web-access/vendor/extract.d.ts +1 -1
  139. package/dist/humain/web-access/vendor/extract.d.ts.map +1 -1
  140. package/dist/humain/web-access/vendor/extract.js +58 -11
  141. package/dist/humain/web-access/vendor/extract.js.map +1 -1
  142. package/dist/humain/web-access/vendor/fetch-params.js +1 -1
  143. package/dist/humain/web-access/vendor/fetch-params.js.map +1 -1
  144. package/dist/humain/web-access/vendor/gemini-search.d.ts +2 -2
  145. package/dist/humain/web-access/vendor/gemini-search.d.ts.map +1 -1
  146. package/dist/humain/web-access/vendor/gemini-search.js +12 -5
  147. package/dist/humain/web-access/vendor/gemini-search.js.map +1 -1
  148. package/dist/humain/web-access/vendor/index.d.ts.map +1 -1
  149. package/dist/humain/web-access/vendor/index.js +23 -19
  150. package/dist/humain/web-access/vendor/index.js.map +1 -1
  151. package/dist/humain/web-access/vendor/openai-search.d.ts.map +1 -1
  152. package/dist/humain/web-access/vendor/openai-search.js +40 -14
  153. package/dist/humain/web-access/vendor/openai-search.js.map +1 -1
  154. package/dist/humain/web-access/vendor/page-query.d.ts.map +1 -1
  155. package/dist/humain/web-access/vendor/page-query.js +6 -2
  156. package/dist/humain/web-access/vendor/page-query.js.map +1 -1
  157. package/dist/humain/web-access/vendor/query-rewrite.d.ts.map +1 -1
  158. package/dist/humain/web-access/vendor/query-rewrite.js +6 -2
  159. package/dist/humain/web-access/vendor/query-rewrite.js.map +1 -1
  160. package/dist/humain/web-access/vendor/serpapi.d.ts +4 -0
  161. package/dist/humain/web-access/vendor/serpapi.d.ts.map +1 -0
  162. package/dist/humain/web-access/vendor/serpapi.js +215 -0
  163. package/dist/humain/web-access/vendor/serpapi.js.map +1 -0
  164. package/dist/humain/web-access/vendor/ssrf-protection.d.ts.map +1 -1
  165. package/dist/humain/web-access/vendor/ssrf-protection.js +5 -1
  166. package/dist/humain/web-access/vendor/ssrf-protection.js.map +1 -1
  167. package/dist/humain/web-access/vendor/storage.d.ts.map +1 -1
  168. package/dist/humain/web-access/vendor/storage.js +9 -0
  169. package/dist/humain/web-access/vendor/storage.js.map +1 -1
  170. package/dist/humain/web-access/vendor/summary-review.d.ts +1 -1
  171. package/dist/humain/web-access/vendor/summary-review.d.ts.map +1 -1
  172. package/dist/humain/web-access/vendor/summary-review.js +35 -24
  173. package/dist/humain/web-access/vendor/summary-review.js.map +1 -1
  174. package/dist/humain/web-access/vendor/utils.d.ts.map +1 -1
  175. package/dist/humain/web-access/vendor/utils.js +5 -3
  176. package/dist/humain/web-access/vendor/utils.js.map +1 -1
  177. package/dist/modes/json-event.d.ts +8 -0
  178. package/dist/modes/json-event.d.ts.map +1 -1
  179. package/dist/modes/json-event.js +17 -0
  180. package/dist/modes/json-event.js.map +1 -1
  181. package/dist/modes/print-mode.d.ts.map +1 -1
  182. package/dist/modes/print-mode.js +3 -2
  183. package/dist/modes/print-mode.js.map +1 -1
  184. package/docs/environment-variables.md +8 -0
  185. package/docs/json.md +16 -0
  186. package/npm-shrinkwrap.json +6 -6
  187. package/package.json +2 -2
  188. package/src/humain/mcp-adapter/UPSTREAM.md +56 -7
  189. package/src/humain/mcp-adapter/upstream.json +6 -4
  190. package/src/humain/mcp-adapter/vendor/CHANGELOG.md +78 -0
  191. package/src/humain/web-access/UPSTREAM.md +18 -8
  192. package/src/humain/web-access/upstream.json +6 -5
  193. package/dist/bundle/chunks/chunk-2LC3S3VV.js +0 -105
@@ -1,7 +1,9 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { isAbsolute, relative, resolve, sep } from "node:path";
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { getAgentPath } from "./agent-dir.ts";
4
+ import { markBuiltInAgentPlugin } from "./agent-plugin-provenance.ts";
4
5
  import type { McpConfig, ServerEntry } from "./types.ts";
6
+ import { resolveRealContainedPath } from "./utils.ts";
5
7
 
6
8
  const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
7
9
  const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
@@ -21,6 +23,8 @@ const PLUGIN_MANIFEST_FIELDS = new Set([
21
23
  const MCP_CONFIG_FIELDS = new Set(["$schema", "mcpServers"]);
22
24
  const STDIO_FIELDS = new Set(["type", "command", "args", "env", "cwd"]);
23
25
  const HTTP_FIELDS = new Set(["type", "url", "headers"]);
26
+ const STRING_MANIFEST_FIELDS = ["version", "description", "homepage", "repository", "license"] as const;
27
+ const AUTHOR_FIELDS = new Set(["name", "email", "url"]);
24
28
 
25
29
  interface AgentPluginManifest {
26
30
  name: string;
@@ -50,7 +54,7 @@ export function loadAgentPluginConfigs(paths: unknown, cwd = process.cwd()): Mcp
50
54
 
51
55
  export function getAgentPluginSummaries(paths: unknown, cwd = process.cwd()): AgentPluginSummary[] {
52
56
  return getPluginPaths(paths).map(path => {
53
- const pluginRoot = resolvePluginPath(path, cwd);
57
+ const pluginRoot = resolvePluginRoot(path, cwd);
54
58
  const loaded = loadAgentPluginMcpConfig(path, cwd);
55
59
  const manifest = loaded ? readPluginManifest(pluginRoot, false) : null;
56
60
  return {
@@ -66,7 +70,7 @@ function getPluginPaths(paths: unknown): string[] {
66
70
  }
67
71
 
68
72
  function loadAgentPluginMcpConfig(path: string, cwd: string): McpConfig | null {
69
- const pluginRoot = resolvePluginPath(path, cwd);
73
+ const pluginRoot = resolvePluginRoot(path, cwd);
70
74
  const manifest = readPluginManifest(pluginRoot, true);
71
75
  if (!manifest) return null;
72
76
 
@@ -76,10 +80,15 @@ function loadAgentPluginMcpConfig(path: string, cwd: string): McpConfig | null {
76
80
  console.warn(`Agent Plugin ${manifest.name} has invalid MCP config: mcp.json is not a regular file`);
77
81
  return { mcpServers: {} };
78
82
  }
83
+ const resolvedMcpPath = resolveRealContainedPath(pluginRoot, mcpPath);
84
+ if (!resolvedMcpPath) {
85
+ console.warn(`Agent Plugin ${manifest.name} has invalid MCP config: mcp.json must stay inside the plugin directory`);
86
+ return { mcpServers: {} };
87
+ }
79
88
 
80
89
  let raw: unknown;
81
90
  try {
82
- raw = JSON.parse(readFileSync(mcpPath, "utf8"));
91
+ raw = JSON.parse(readFileSync(resolvedMcpPath, "utf8"));
83
92
  } catch (error) {
84
93
  console.warn(`Agent Plugin ${manifest.name} has invalid MCP config: failed to parse mcp.json`, error);
85
94
  return { mcpServers: {} };
@@ -98,10 +107,15 @@ function readPluginManifest(pluginRoot: string, report: boolean): AgentPluginMan
98
107
  if (report) console.warn(`Agent Plugin at ${pluginRoot} is invalid: plugin.json is not a regular file`);
99
108
  return null;
100
109
  }
110
+ const resolvedManifestPath = resolveRealContainedPath(pluginRoot, manifestPath);
111
+ if (!resolvedManifestPath) {
112
+ if (report) console.warn(`Agent Plugin at ${pluginRoot} is invalid: plugin.json must stay inside the plugin directory`);
113
+ return null;
114
+ }
101
115
 
102
116
  let raw: unknown;
103
117
  try {
104
- raw = JSON.parse(readFileSync(manifestPath, "utf8"));
118
+ raw = JSON.parse(readFileSync(resolvedManifestPath, "utf8"));
105
119
  } catch (error) {
106
120
  if (report) console.warn(`Agent Plugin at ${pluginRoot} is invalid: failed to parse plugin.json`, error);
107
121
  return null;
@@ -125,6 +139,20 @@ function readPluginManifest(pluginRoot: string, report: boolean): AgentPluginMan
125
139
  if (report) console.warn(`Agent Plugin at ${pluginRoot} is invalid: plugin.json name is invalid`);
126
140
  return null;
127
141
  }
142
+ for (const field of STRING_MANIFEST_FIELDS) {
143
+ if (manifest[field] !== undefined && typeof manifest[field] !== "string") {
144
+ if (report) console.warn(`Agent Plugin ${manifest.name} is invalid: plugin.json ${field} must be a string`);
145
+ return null;
146
+ }
147
+ }
148
+ if (manifest.keywords !== undefined && (!Array.isArray(manifest.keywords) || manifest.keywords.some(value => typeof value !== "string"))) {
149
+ if (report) console.warn(`Agent Plugin ${manifest.name} is invalid: plugin.json keywords must be an array of strings`);
150
+ return null;
151
+ }
152
+ if (manifest.author !== undefined && !isValidManifestAuthor(manifest.author)) {
153
+ if (report) console.warn(`Agent Plugin ${manifest.name} is invalid: plugin.json author is invalid`);
154
+ return null;
155
+ }
128
156
  if (manifest.extensions !== undefined && (!manifest.extensions || typeof manifest.extensions !== "object" || Array.isArray(manifest.extensions))) {
129
157
  if (report) console.warn(`Agent Plugin ${manifest.name} ignores non-object plugin.json extensions`);
130
158
  }
@@ -205,14 +233,14 @@ function translateStdioServer(
205
233
  const env = translateEnv(raw.env, manifest, serverName);
206
234
  if (env === null) return null;
207
235
 
208
- const command = raw.command.startsWith("./") ? resolveContainedPath(pluginRoot, raw.command, pluginRoot) : raw.command;
209
- if (command === null) return skipServer(manifest, serverName, "command must stay inside the plugin directory");
236
+ const command = raw.command.startsWith("./") ? resolveRealContainedPath(pluginRoot, resolve(pluginRoot, raw.command)) : raw.command;
237
+ if (command === null) return skipServer(manifest, serverName, "command must resolve to an accessible path inside the plugin directory");
210
238
 
211
239
  const pluginDataDir = getAgentPath("agent-plugin-data", manifest.name);
212
240
  const cwd = resolvePluginCwd(raw.cwd, pluginRoot, pluginDataDir);
213
- if (cwd === null) return skipServer(manifest, serverName, "cwd must be plugin-relative, PLUGIN_ROOT-rooted, or PLUGIN_DATA-rooted");
241
+ if (cwd === null) return skipServer(manifest, serverName, "cwd must resolve from an allowed root and stay contained");
214
242
 
215
- return {
243
+ return markBuiltInAgentPlugin({
216
244
  command,
217
245
  args: args.map(value => expandPluginPlaceholders(value, pluginRoot, pluginDataDir)),
218
246
  env: {
@@ -223,7 +251,7 @@ function translateStdioServer(
223
251
  cwd,
224
252
  pluginDataDir,
225
253
  literalEnv: true,
226
- };
254
+ }, ["args", "env", "cwd"]);
227
255
  }
228
256
 
229
257
  function translateHttpServer(
@@ -240,11 +268,11 @@ function translateHttpServer(
240
268
  const headers = translateHeaders(raw.headers, manifest, serverName);
241
269
  if (headers === null) return null;
242
270
 
243
- return {
271
+ return markBuiltInAgentPlugin({
244
272
  url: raw.url,
245
273
  httpTransport: type,
246
274
  ...(headers ? { headers } : {}),
247
- };
275
+ }, headers ? ["headers"] : []);
248
276
  }
249
277
 
250
278
  function formatAgentPluginServerName(pluginName: string, serverName: string): string {
@@ -273,7 +301,7 @@ function translateEnv(value: unknown, manifest: AgentPluginManifest, serverName:
273
301
  console.warn(`Agent Plugin ${manifest.name} skips invalid MCP server ${serverName}: env must be an object of strings`);
274
302
  return null;
275
303
  }
276
- const env: Record<string, string> = {};
304
+ const entries: Array<[string, string]> = [];
277
305
  for (const [key, entry] of Object.entries(value)) {
278
306
  if (key === "PLUGIN_ROOT" || key === "PLUGIN_DATA") {
279
307
  console.warn(`Agent Plugin ${manifest.name} skips invalid MCP server ${serverName}: env must not define ${key}`);
@@ -283,9 +311,9 @@ function translateEnv(value: unknown, manifest: AgentPluginManifest, serverName:
283
311
  console.warn(`Agent Plugin ${manifest.name} skips invalid MCP server ${serverName}: env values must be strings`);
284
312
  return null;
285
313
  }
286
- env[key] = entry;
314
+ entries.push([key, entry]);
287
315
  }
288
- return env;
316
+ return Object.fromEntries(entries);
289
317
  }
290
318
 
291
319
  function translateHeaders(value: unknown, manifest: AgentPluginManifest, serverName: string): Record<string, string> | undefined | null {
@@ -294,7 +322,7 @@ function translateHeaders(value: unknown, manifest: AgentPluginManifest, serverN
294
322
  console.warn(`Agent Plugin ${manifest.name} skips invalid MCP server ${serverName}: headers must be an object of strings`);
295
323
  return null;
296
324
  }
297
- const headers: Record<string, string> = {};
325
+ const entries: Array<[string, string]> = [];
298
326
  const seen = new Set<string>();
299
327
  for (const [key, entry] of Object.entries(value)) {
300
328
  if (typeof entry !== "string") {
@@ -307,8 +335,9 @@ function translateHeaders(value: unknown, manifest: AgentPluginManifest, serverN
307
335
  return null;
308
336
  }
309
337
  seen.add(normalized);
310
- headers[key] = entry;
338
+ entries.push([key, entry]);
311
339
  }
340
+ const headers = Object.fromEntries(entries);
312
341
  try {
313
342
  new Headers(headers);
314
343
  } catch {
@@ -324,6 +353,20 @@ function resolvePluginPath(path: string, cwd: string): string {
324
353
  return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
325
354
  }
326
355
 
356
+ function resolvePluginRoot(path: string, cwd: string): string {
357
+ const resolved = resolvePluginPath(path, cwd);
358
+ try {
359
+ return realpathSync(resolved);
360
+ } catch {
361
+ return resolved;
362
+ }
363
+ }
364
+
365
+ function isValidManifestAuthor(value: unknown): boolean {
366
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
367
+ return Object.entries(value).every(([key, entry]) => AUTHOR_FIELDS.has(key) && typeof entry === "string");
368
+ }
369
+
327
370
  function isBareCommand(command: string): boolean {
328
371
  return !command.includes("/") && !command.includes("\\") && !command.includes("${PLUGIN_ROOT}") && !command.includes("${PLUGIN_DATA}");
329
372
  }
@@ -331,27 +374,23 @@ function isBareCommand(command: string): boolean {
331
374
  function resolvePluginCwd(value: unknown, pluginRoot: string, pluginDataDir: string): string | null {
332
375
  if (value === undefined) return pluginRoot;
333
376
  if (typeof value !== "string") return null;
334
- if (value.startsWith("./")) return resolveContainedPath(pluginRoot, value, pluginRoot);
335
- if (value === "${PLUGIN_ROOT}" || value.startsWith("${PLUGIN_ROOT}/")) {
336
- return resolveContainedPath(pluginRoot, value.replace("${PLUGIN_ROOT}", "."), pluginRoot);
377
+ const expanded = expandPluginPlaceholders(value, pluginRoot, pluginDataDir);
378
+ if (value.startsWith("./") || value === "${PLUGIN_ROOT}" || value.startsWith("${PLUGIN_ROOT}/")) {
379
+ return resolveRealContainedPath(pluginRoot, resolve(pluginRoot, expanded));
337
380
  }
338
381
  if (value === "${PLUGIN_DATA}" || value.startsWith("${PLUGIN_DATA}/")) {
339
- return resolveContainedPath(pluginDataDir, value.replace("${PLUGIN_DATA}", "."), pluginDataDir);
382
+ return resolvePluginDataCwd(pluginDataDir, expanded);
340
383
  }
341
384
  return null;
342
385
  }
343
386
 
344
- function resolveContainedPath(root: string, value: string, containmentRoot: string): string | null {
345
- const resolved = resolve(root, value);
346
- const rel = relative(containmentRoot, resolved);
347
- if (rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep) && !isAbsolute(rel))) return resolved;
348
- return null;
387
+ function resolvePluginDataCwd(pluginDataDir: string, expanded: string): string | null {
388
+ if (existsSync(pluginDataDir) && !resolveRealContainedPath(dirname(pluginDataDir), pluginDataDir)) return null;
389
+ return resolveRealContainedPath(pluginDataDir, resolve(pluginDataDir, expanded), true);
349
390
  }
350
391
 
351
392
  function expandPluginPlaceholders(value: string, pluginRoot: string, pluginDataDir: string): string {
352
- return value
353
- .replaceAll("${PLUGIN_ROOT}", pluginRoot)
354
- .replaceAll("${PLUGIN_DATA}", pluginDataDir);
393
+ return value.replace(/\$\{PLUGIN_(ROOT|DATA)\}/g, (_, name: string) => name === "ROOT" ? pluginRoot : pluginDataDir);
355
394
  }
356
395
 
357
396
  function isValidAgentPluginUrl(value: string): boolean {
@@ -0,0 +1,36 @@
1
+ import type { ServerEntry } from "./types.ts";
2
+
3
+ const LITERAL_PLUGIN_FIELDS = ["args", "env", "cwd", "headers"] as const;
4
+ type LiteralPluginField = typeof LITERAL_PLUGIN_FIELDS[number];
5
+ const BUILT_IN_AGENT_PLUGIN = Symbol("built-in-agent-plugin");
6
+ type BuiltInAgentPluginEntry = ServerEntry & { [BUILT_IN_AGENT_PLUGIN]?: ReadonlySet<LiteralPluginField> };
7
+
8
+ /** @internal */
9
+ export function markBuiltInAgentPlugin(definition: ServerEntry, fields: LiteralPluginField[]): ServerEntry {
10
+ (definition as BuiltInAgentPluginEntry)[BUILT_IN_AGENT_PLUGIN] = new Set(fields);
11
+ return definition;
12
+ }
13
+
14
+ /** @internal */
15
+ export function isBuiltInAgentPlugin(definition: ServerEntry, field: LiteralPluginField): boolean {
16
+ return (definition as BuiltInAgentPluginEntry)[BUILT_IN_AGENT_PLUGIN]?.has(field) === true;
17
+ }
18
+
19
+ /** @internal */
20
+ export function cloneBuiltInAgentPluginEntry(source: ServerEntry): ServerEntry | undefined {
21
+ const fields = LITERAL_PLUGIN_FIELDS.filter(field => Object.hasOwn(source, field) && isBuiltInAgentPlugin(source, field));
22
+ if (fields.length === 0) return undefined;
23
+ return markBuiltInAgentPlugin(structuredClone(source), fields);
24
+ }
25
+
26
+ /** @internal */
27
+ export function mergeBuiltInAgentPluginEntries(base: ServerEntry, next: ServerEntry): ServerEntry {
28
+ const merged = { ...base, ...next };
29
+ delete (merged as BuiltInAgentPluginEntry)[BUILT_IN_AGENT_PLUGIN];
30
+ const fields = LITERAL_PLUGIN_FIELDS.filter(field => {
31
+ const owner = Object.hasOwn(next, field) ? next : base;
32
+ return Object.hasOwn(owner, field) && isBuiltInAgentPlugin(owner, field);
33
+ });
34
+ if (fields.length > 0) markBuiltInAgentPlugin(merged, fields);
35
+ return merged;
36
+ }
@@ -0,0 +1,365 @@
1
+ import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readdirSync, realpathSync, statSync, type Dirent, type Stats } from "node:fs";
2
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { formatServerNamespace, type ClaudePluginConfig, type McpConfig, type ServerEntry } from "./types.ts";
4
+ import { parseJsonWithComments } from "./utils.ts";
5
+
6
+ interface ClaudePluginManifest {
7
+ name: string;
8
+ }
9
+
10
+ export interface ClaudePluginBundleResult {
11
+ mcpServers: Record<string, ServerEntry>;
12
+ skillPaths: string[];
13
+ }
14
+
15
+ export function loadClaudePluginBundles(
16
+ plugins: readonly ClaudePluginConfig[] | undefined,
17
+ cwd: string,
18
+ validateConfig: (raw: unknown) => McpConfig,
19
+ components: { mcp: boolean; skills: boolean },
20
+ ): ClaudePluginBundleResult {
21
+ const mcpServers: Record<string, ServerEntry> = {};
22
+ const skillPaths: string[] = [];
23
+ const seenRoots = new Set<string>();
24
+ const seenNames = new Map<string, string>();
25
+ const serverSources = new Map<string, string>();
26
+ const normalizedServerNames = new Map<string, string>();
27
+
28
+ for (const plugin of plugins ?? []) {
29
+ const configuredPath = resolvePluginPath(plugin.path, cwd);
30
+ const pluginRoot = resolvePluginRoot(configuredPath);
31
+ if (!pluginRoot) continue;
32
+ if (seenRoots.has(pluginRoot)) {
33
+ console.warn(`Claude plugin path is configured more than once: ${pluginRoot}`);
34
+ continue;
35
+ }
36
+ seenRoots.add(pluginRoot);
37
+
38
+ const manifest = readPluginManifest(pluginRoot);
39
+ if (manifest === false) continue;
40
+ const pluginName = manifest?.name ?? basename(pluginRoot);
41
+ const previousPluginRoot = seenNames.get(pluginName);
42
+ if (previousPluginRoot && previousPluginRoot !== pluginRoot) {
43
+ console.warn(`Claude plugin name conflict for "${pluginName}": ${previousPluginRoot} and ${pluginRoot}`);
44
+ } else {
45
+ seenNames.set(pluginName, pluginRoot);
46
+ }
47
+
48
+ if (components.mcp && plugin.mcp === true) {
49
+ const configPath = resolveContainedComponent(pluginRoot, ".mcp.json", "file", pluginName);
50
+ if (configPath) {
51
+ const config = readPluginMcpConfig(configPath, pluginName, pluginRoot, validateConfig);
52
+ for (const [serverName, server] of Object.entries(config.mcpServers)) {
53
+ const normalizedName = formatServerNamespace(serverName);
54
+ const previousName = normalizedServerNames.get(normalizedName);
55
+ const previousSource = previousName ? serverSources.get(previousName) : undefined;
56
+ if (previousName && previousSource) {
57
+ console.warn(`Claude plugin MCP server name conflict for "${serverName}": normalized name collides with "${previousName}" from ${previousSource}; the first plugin wins over ${configPath}`);
58
+ continue;
59
+ }
60
+ normalizedServerNames.set(normalizedName, serverName);
61
+ serverSources.set(serverName, configPath);
62
+ mcpServers[serverName] = server;
63
+ }
64
+ }
65
+ }
66
+
67
+ if (components.skills && plugin.skills === true) {
68
+ const skillsPath = resolveContainedComponent(pluginRoot, "skills", "directory", pluginName);
69
+ if (skillsPath) {
70
+ try {
71
+ for (const skillPath of discoverContainedSkillFiles(skillsPath, pluginName)) {
72
+ if (!skillPaths.includes(skillPath)) skillPaths.push(skillPath);
73
+ }
74
+ } catch (error) {
75
+ console.warn(`Claude plugin "${pluginName}" skills could not be read at ${skillsPath}: ${formatError(error)}`);
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ return { mcpServers, skillPaths };
82
+ }
83
+
84
+ function resolvePluginPath(path: string, cwd: string): string {
85
+ if (path === "~") return resolve(process.env.HOME ?? "", ".");
86
+ if (path.startsWith("~/")) return resolve(process.env.HOME ?? "", path.slice(2));
87
+ return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
88
+ }
89
+
90
+ function resolvePluginRoot(path: string): string | null {
91
+ if (!existsSync(path)) {
92
+ console.warn(`Claude plugin path does not exist: ${path}`);
93
+ return null;
94
+ }
95
+ const stats = safeStat(path, "Claude plugin path");
96
+ if (!stats) return null;
97
+ if (!stats.isDirectory()) {
98
+ console.warn(`Claude plugin path is not a directory: ${path}`);
99
+ return null;
100
+ }
101
+ return safeRealpath(path, "Claude plugin path");
102
+ }
103
+
104
+ function readPluginManifest(pluginRoot: string): ClaudePluginManifest | null | false {
105
+ const configuredManifestPath = resolve(pluginRoot, ".claude-plugin", "plugin.json");
106
+ if (!existsSync(configuredManifestPath)) return null;
107
+ const stats = safeStat(configuredManifestPath, "Claude plugin manifest");
108
+ if (!stats) return null;
109
+ if (!stats.isFile()) {
110
+ console.warn(`Claude plugin manifest is not a regular file: ${configuredManifestPath}`);
111
+ return false;
112
+ }
113
+ const manifestPath = safeRealpath(configuredManifestPath, "Claude plugin manifest");
114
+ if (!manifestPath) return null;
115
+ if (!isContainedPath(pluginRoot, manifestPath)) {
116
+ console.warn(`Claude plugin manifest resolves outside the configured plugin directory: ${manifestPath}`);
117
+ return false;
118
+ }
119
+
120
+ const manifestText = safeReadFile(manifestPath, "Claude plugin manifest", pluginRoot);
121
+ if (manifestText === null) return null;
122
+
123
+ let raw: unknown;
124
+ try {
125
+ raw = parseJsonWithComments(manifestText);
126
+ } catch (error) {
127
+ console.warn(`Claude plugin manifest contains invalid JSON at ${manifestPath}: ${formatError(error)}`);
128
+ return false;
129
+ }
130
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
131
+ console.warn(`Claude plugin manifest must be a JSON object: ${manifestPath}`);
132
+ return false;
133
+ }
134
+
135
+ const manifest = raw as Record<string, unknown>;
136
+ if (typeof manifest.name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.name)) {
137
+ console.warn(`Claude plugin manifest has an invalid name at ${manifestPath}: expected kebab-case`);
138
+ return false;
139
+ }
140
+ if (!hasValidManifestFieldTypes(manifest)) {
141
+ console.warn(`Claude plugin manifest has invalid field types at ${manifestPath}`);
142
+ return false;
143
+ }
144
+ return { name: manifest.name };
145
+ }
146
+
147
+ function hasValidManifestFieldTypes(manifest: Record<string, unknown>): boolean {
148
+ for (const field of ["$schema", "displayName", "version", "description", "homepage", "repository", "license"] as const) {
149
+ if (manifest[field] !== undefined && typeof manifest[field] !== "string") return false;
150
+ }
151
+ for (const field of ["commands", "agents", "skills", "hooks", "mcpServers", "lspServers", "outputStyles"] as const) {
152
+ const value = manifest[field];
153
+ if (value !== undefined && typeof value !== "string" && (!Array.isArray(value) || value.some(item => typeof item !== "string"))) return false;
154
+ }
155
+ if (manifest.defaultEnabled !== undefined && typeof manifest.defaultEnabled !== "boolean") return false;
156
+ if (manifest.keywords !== undefined && (!Array.isArray(manifest.keywords) || manifest.keywords.some(value => typeof value !== "string"))) return false;
157
+ if (manifest.author !== undefined) {
158
+ if (!manifest.author || typeof manifest.author !== "object" || Array.isArray(manifest.author)) return false;
159
+ const author = manifest.author as Record<string, unknown>;
160
+ if (typeof author.name !== "string") return false;
161
+ if (author.email !== undefined && typeof author.email !== "string") return false;
162
+ if (author.url !== undefined && typeof author.url !== "string") return false;
163
+ }
164
+ if (manifest.metadata !== undefined && (!manifest.metadata || typeof manifest.metadata !== "object" || Array.isArray(manifest.metadata))) return false;
165
+ return true;
166
+ }
167
+
168
+ function resolveContainedComponent(
169
+ pluginRoot: string,
170
+ component: string,
171
+ expected: "file" | "directory",
172
+ pluginName: string,
173
+ ): string | null {
174
+ const path = resolve(pluginRoot, component);
175
+ if (!existsSync(path)) {
176
+ console.warn(`Claude plugin "${pluginName}" is missing ${component}: ${path}`);
177
+ return null;
178
+ }
179
+ const stats = safeStat(path, `Claude plugin "${pluginName}" ${component}`);
180
+ if (!stats) return null;
181
+ if (expected === "file" ? !stats.isFile() : !stats.isDirectory()) {
182
+ console.warn(`Claude plugin "${pluginName}" ${component} is not a ${expected}: ${path}`);
183
+ return null;
184
+ }
185
+ const realPath = safeRealpath(path, `Claude plugin "${pluginName}" ${component}`);
186
+ if (!realPath) return null;
187
+ if (!isContainedPath(pluginRoot, realPath)) {
188
+ console.warn(`Claude plugin "${pluginName}" ${component} resolves outside the configured plugin directory: ${realPath}`);
189
+ return null;
190
+ }
191
+ return realPath;
192
+ }
193
+
194
+ function discoverContainedSkillFiles(skillsRoot: string, pluginName: string): string[] {
195
+ const skillFiles: string[] = [];
196
+ const visitedDirectories = new Set<string>();
197
+ const visit = (directory: string): void => {
198
+ const realDirectory = safeRealpath(directory, `Claude plugin "${pluginName}" skill directory`);
199
+ if (!realDirectory) return;
200
+ if (!isContainedPath(skillsRoot, realDirectory)) {
201
+ console.warn(`Claude plugin "${pluginName}" skill path resolves outside its skills directory: ${directory} -> ${realDirectory}`);
202
+ return;
203
+ }
204
+ if (visitedDirectories.has(realDirectory)) return;
205
+ visitedDirectories.add(realDirectory);
206
+ const entries = safeReadDir(realDirectory, `Claude plugin "${pluginName}" skill directory`);
207
+ if (!entries) return;
208
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
209
+ const path = resolve(realDirectory, entry.name);
210
+ if (entry.isSymbolicLink()) {
211
+ const target = safeRealpath(path, `Claude plugin "${pluginName}" skill path`);
212
+ if (!target) continue;
213
+ if (!isContainedPath(skillsRoot, target)) {
214
+ console.warn(`Claude plugin "${pluginName}" skill path resolves outside its skills directory: ${path} -> ${target}`);
215
+ continue;
216
+ }
217
+ const targetStats = safeStat(target, `Claude plugin "${pluginName}" skill path`);
218
+ if (!targetStats) continue;
219
+ if (targetStats.isDirectory()) visit(target);
220
+ else if (targetStats.isFile() && entry.name === "SKILL.md") skillFiles.push(target);
221
+ } else if (entry.isDirectory()) {
222
+ visit(path);
223
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
224
+ const realPath = safeRealpath(path, `Claude plugin "${pluginName}" skill path`);
225
+ if (!realPath) continue;
226
+ if (!isContainedPath(skillsRoot, realPath)) {
227
+ console.warn(`Claude plugin "${pluginName}" skill path resolves outside its skills directory: ${path} -> ${realPath}`);
228
+ continue;
229
+ }
230
+ skillFiles.push(realPath);
231
+ }
232
+ }
233
+ };
234
+ visit(skillsRoot);
235
+ return skillFiles;
236
+ }
237
+
238
+ function readPluginMcpConfig(
239
+ configPath: string,
240
+ pluginName: string,
241
+ pluginRoot: string,
242
+ validateConfig: (raw: unknown) => McpConfig,
243
+ ): McpConfig {
244
+ let raw: unknown;
245
+ try {
246
+ const configText = safeReadFile(configPath, `Claude plugin "${pluginName}" MCP config`, pluginRoot);
247
+ if (configText === null) return { mcpServers: {} };
248
+ raw = parseJsonWithComments(configText);
249
+ } catch (error) {
250
+ console.warn(`Claude plugin "${pluginName}" has invalid MCP config at ${configPath}: ${formatError(error)}`);
251
+ return { mcpServers: {} };
252
+ }
253
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
254
+ console.warn(`Claude plugin "${pluginName}" MCP config must be a JSON object: ${configPath}`);
255
+ return { mcpServers: {} };
256
+ }
257
+
258
+ const servers = (raw as Record<string, unknown>).mcpServers;
259
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
260
+ console.warn(`Claude plugin "${pluginName}" MCP config must contain an mcpServers object: ${configPath}`);
261
+ return { mcpServers: {} };
262
+ }
263
+
264
+ const config = validateConfig(raw);
265
+ const validated: Record<string, ServerEntry> = {};
266
+ for (const serverName of Object.keys(servers)) {
267
+ if (serverName.trim().length === 0) {
268
+ console.warn(`Claude plugin "${pluginName}" has an empty MCP server name at ${configPath}`);
269
+ continue;
270
+ }
271
+ const parsed = config.mcpServers[serverName];
272
+ if (!parsed || !hasValidServerTransport(parsed)) {
273
+ console.warn(`Claude plugin "${pluginName}" has invalid MCP server "${serverName}" at ${configPath}: expected exactly one non-empty command, url, or socket transport`);
274
+ continue;
275
+ }
276
+ validated[serverName] = replacePluginRoot(parsed, pluginRoot);
277
+ }
278
+ return { mcpServers: validated };
279
+ }
280
+
281
+ function hasValidServerTransport(entry: ServerEntry): boolean {
282
+ return [entry.command, entry.url, entry.socket].filter(value => typeof value === "string" && value.trim().length > 0).length === 1;
283
+ }
284
+
285
+ function replacePluginRoot(entry: ServerEntry, pluginRoot: string): ServerEntry {
286
+ const replace = (value: string): string => value.replaceAll("${CLAUDE_PLUGIN_ROOT}", pluginRoot);
287
+ const replaced = replaceStrings(entry, replace) as ServerEntry;
288
+ if (typeof replaced.command === "string") {
289
+ replaced.env = { ...replaced.env, CLAUDE_PLUGIN_ROOT: pluginRoot };
290
+ }
291
+ return replaced;
292
+ }
293
+
294
+ function replaceStrings(value: unknown, replace: (value: string) => string): unknown {
295
+ if (typeof value === "string") return replace(value);
296
+ if (Array.isArray(value)) return value.map(item => replaceStrings(item, replace));
297
+ if (!value || typeof value !== "object") return value;
298
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, replaceStrings(entry, replace)]));
299
+ }
300
+
301
+ function isContainedPath(root: string, path: string): boolean {
302
+ const rel = relative(root, path);
303
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep) && !isAbsolute(rel));
304
+ }
305
+
306
+ function safeStat(path: string, description: string): Stats | null {
307
+ try {
308
+ return statSync(path);
309
+ } catch (error) {
310
+ console.warn(`${description} could not be read: ${path}: ${formatError(error)}`);
311
+ return null;
312
+ }
313
+ }
314
+
315
+ function safeRealpath(path: string, description: string): string | null {
316
+ try {
317
+ return realpathSync(path);
318
+ } catch (error) {
319
+ console.warn(`${description} could not be read: ${path}: ${formatError(error)}`);
320
+ return null;
321
+ }
322
+ }
323
+
324
+ function safeReadFile(path: string, description: string, containmentRoot: string): string | null {
325
+ let fd: number | undefined;
326
+ try {
327
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
328
+ const openedStats = fstatSync(fd);
329
+ if (!openedStats.isFile()) {
330
+ console.warn(`${description} is not a regular file: ${path}`);
331
+ return null;
332
+ }
333
+ const resolvedPath = safeRealpath(path, description);
334
+ if (!resolvedPath) return null;
335
+ if (!isContainedPath(containmentRoot, resolvedPath)) {
336
+ console.warn(`${description} resolves outside the configured plugin directory: ${resolvedPath}`);
337
+ return null;
338
+ }
339
+ const currentStats = safeStat(resolvedPath, description);
340
+ if (!currentStats) return null;
341
+ if (openedStats.dev !== currentStats.dev || openedStats.ino !== currentStats.ino) {
342
+ console.warn(`${description} changed while being read: ${path}`);
343
+ return null;
344
+ }
345
+ return readFileSync(fd, "utf8");
346
+ } catch (error) {
347
+ console.warn(`${description} could not be read: ${path}: ${formatError(error)}`);
348
+ return null;
349
+ } finally {
350
+ if (fd !== undefined) closeSync(fd);
351
+ }
352
+ }
353
+
354
+ function safeReadDir(path: string, description: string): Dirent[] | null {
355
+ try {
356
+ return readdirSync(path, { withFileTypes: true });
357
+ } catch (error) {
358
+ console.warn(`${description} could not be read: ${path}: ${formatError(error)}`);
359
+ return null;
360
+ }
361
+ }
362
+
363
+ function formatError(error: unknown): string {
364
+ return error instanceof Error ? error.message : String(error);
365
+ }