@xpufx/paseo-forges 0.1.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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/client/active-forge.ts +47 -0
  4. package/client/board-alert.tsx +236 -0
  5. package/client/foreign-link.tsx +33 -0
  6. package/client/hook-queue-panel.tsx +412 -0
  7. package/client/issues-pill.tsx +1976 -0
  8. package/client/label-chip.tsx +116 -0
  9. package/client/linkifier.tsx +280 -0
  10. package/client/pill-label.ts +183 -0
  11. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  12. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  13. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  14. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  15. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  16. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  17. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  18. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  19. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  20. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  21. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  22. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  23. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  24. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  25. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  26. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  27. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  28. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  29. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  30. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  31. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  32. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  33. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  34. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  35. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  36. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  37. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  38. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  39. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  40. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  41. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  42. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  43. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  44. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  45. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  46. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  47. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  48. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  49. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  50. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  51. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  52. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  53. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  54. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  55. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  56. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  57. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  58. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  59. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  60. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  61. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  62. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  63. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  64. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  65. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  66. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  67. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  68. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  69. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  70. package/client/webhook-card.tsx +198 -0
  71. package/docs/specs/forge-workflow-gui.md +820 -0
  72. package/docs/workflow.md +339 -0
  73. package/examples/README.md +108 -0
  74. package/examples/hook-service/README.md +66 -0
  75. package/examples/hook-service/forge-hook.service +28 -0
  76. package/examples/hook-service/hook-server.mjs +265 -0
  77. package/examples/hook-service/hook.env.example +21 -0
  78. package/examples/labels/README.md +64 -0
  79. package/examples/labels/label-base.yaml +117 -0
  80. package/examples/skills/coding-agent/SKILL.md +262 -0
  81. package/examples/skills/coding-agent-fgjx/SKILL.md +271 -0
  82. package/examples/skills/orchestrator/SKILL.md +133 -0
  83. package/examples/skills/orchestrator-fgjx/SKILL.md +139 -0
  84. package/examples/tools/README.md +68 -0
  85. package/examples/tools/fgjx +464 -0
  86. package/package.json +38 -0
  87. package/paseo-plugin.json +4 -0
  88. package/server/forge-client.ts +484 -0
  89. package/server/forge-guard.ts +70 -0
  90. package/server/git-origin.ts +70 -0
  91. package/server/hook-queue.ts +127 -0
  92. package/server/issues.ts +542 -0
  93. package/server/settings.ts +52 -0
  94. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  95. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  96. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  97. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  98. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  99. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  100. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  101. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  102. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  103. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  104. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  105. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  106. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  107. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  108. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  109. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  110. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  111. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  112. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  113. package/shared/hook-queue.ts +140 -0
  114. package/shared/issues.ts +1750 -0
  115. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  116. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  117. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  118. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  119. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  120. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  121. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  122. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  123. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  124. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  125. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  126. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  127. package/shared/version.ts +2 -0
  128. package/shared/webhook.ts +223 -0
@@ -0,0 +1,210 @@
1
+ import { redactSecrets } from "./redact";
2
+ import { resolvePluginVersion } from "./version";
3
+
4
+ export type LogLevel = "debug" | "info" | "warn" | "error";
5
+
6
+ const LEVEL_SEVERITY: Record<LogLevel, number> = {
7
+ debug: 10,
8
+ info: 20,
9
+ warn: 30,
10
+ error: 40,
11
+ };
12
+
13
+ function isLogLevel(value: unknown): value is LogLevel {
14
+ return (
15
+ value === "debug" || value === "info" || value === "warn" || value === "error"
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Resolves the minimum log level from the environment. Precedence:
21
+ * `PASEO_PLUGIN_LOG_LEVEL` > `PASEO_LOG_LEVEL` > `PASEO_DEBUG=1` (debug).
22
+ * Returns undefined when nothing is set so callers can apply their default.
23
+ */
24
+ export function resolveMinLevelFromEnv(
25
+ env: NodeJS.ProcessEnv = process.env,
26
+ ): LogLevel | undefined {
27
+ const raw = env.PASEO_PLUGIN_LOG_LEVEL ?? env.PASEO_LOG_LEVEL;
28
+ if (typeof raw === "string" && isLogLevel(raw.trim().toLowerCase())) {
29
+ return raw.trim().toLowerCase() as LogLevel;
30
+ }
31
+ const debugFlag = env.PASEO_DEBUG ?? env.PASEO_PLUGIN_DEBUG;
32
+ if (
33
+ typeof debugFlag === "string" &&
34
+ ["1", "true", "yes", "debug"].includes(debugFlag.trim().toLowerCase())
35
+ ) {
36
+ return "debug";
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ /**
42
+ * True when running in production. Retained for callers that need a
43
+ * production check; note the default log level no longer keys off this.
44
+ */
45
+ export function isProductionEnv(env: NodeJS.ProcessEnv = process.env): boolean {
46
+ return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
47
+ }
48
+
49
+ /**
50
+ * True only when `NODE_ENV` is explicitly a development value
51
+ * (`development`/`dev`). Unset, empty, `production`, and anything else
52
+ * (e.g. `test`) all count as quiet, so a shipped plugin defaults to info
53
+ * without any env var set.
54
+ */
55
+ export function isDevelopmentEnv(env: NodeJS.ProcessEnv = process.env): boolean {
56
+ const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
57
+ return nodeEnv === "development" || nodeEnv === "dev";
58
+ }
59
+
60
+ /**
61
+ * Default rule (documented for operators):
62
+ * explicit `PASEO_PLUGIN_LOG_LEVEL`/`PASEO_LOG_LEVEL`/`PASEO_DEBUG` always wins;
63
+ * otherwise quiet (`info`) — including when no env var is set at all, so a
64
+ * shipped plugin never emits debug logs by default. Debug only when `NODE_ENV`
65
+ * is explicitly a development value (`development`/`dev`).
66
+ */
67
+ export function resolveDefaultMinLevel(
68
+ env: NodeJS.ProcessEnv = process.env,
69
+ ): LogLevel {
70
+ return resolveMinLevelFromEnv(env) ?? (isDevelopmentEnv(env) ? "debug" : "info");
71
+ }
72
+
73
+ export interface PluginLoggerOptions {
74
+ /**
75
+ * Version of the plugin.
76
+ * If omitted, automatically resolves from `package.json` (augmented by git tag/hash).
77
+ */
78
+ version?: string;
79
+
80
+ /**
81
+ * Whether to emit a formatted startup banner on initialization.
82
+ * Defaults to `true`.
83
+ */
84
+ banner?: boolean;
85
+
86
+ /**
87
+ * Subsystem or module tag within the plugin (e.g. "poller", "mcp-client").
88
+ */
89
+ subsystem?: string;
90
+
91
+ /**
92
+ * Minimum log level to print. Defaults to resolveDefaultMinLevel():
93
+ * info unless NODE_ENV is explicitly development (or an explicit level is set).
94
+ */
95
+ minLevel?: LogLevel;
96
+
97
+ /**
98
+ * Optional custom metadata key-values to include in the startup banner.
99
+ */
100
+ meta?: Record<string, string | number | boolean>;
101
+ }
102
+
103
+ export interface PluginLogger {
104
+ debug(message: string, data?: unknown): void;
105
+ info(message: string, data?: unknown): void;
106
+ warn(message: string, data?: unknown): void;
107
+ error(message: string, data?: unknown): void;
108
+ /** Surfaces a caught/suppressed error at debug level so it reaches the plugin log. */
109
+ suppressed(context: string, error: unknown): void;
110
+ child(subsystemOrOptions: string | Partial<PluginLoggerOptions>): PluginLogger;
111
+ }
112
+
113
+ function formatData(data: unknown): string {
114
+ if (data === undefined) return "";
115
+ if (data instanceof Error) {
116
+ return `error="${data.message}"${data.stack ? `\n${data.stack}` : ""}`;
117
+ }
118
+
119
+ const sanitized = redactSecrets(data);
120
+ if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) {
121
+ const pairs = Object.entries(sanitized as Record<string, unknown>).map(
122
+ ([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : String(v)}`,
123
+ );
124
+ return pairs.join(" ");
125
+ }
126
+
127
+ return typeof sanitized === "string" ? sanitized : JSON.stringify(sanitized);
128
+ }
129
+
130
+ /**
131
+ * Creates a structured logger for Paseo plugins.
132
+ * By default, displays the plugin name and version in startup logs and tags each line
133
+ * for Paseo's log stream without fragmented multi-line JSON.
134
+ *
135
+ * If `options.version` is omitted, it automatically resolves the version from `package.json`
136
+ * augmented with git metadata.
137
+ */
138
+ export function createPluginLogger(
139
+ pluginId: string,
140
+ options: PluginLoggerOptions = {},
141
+ ): PluginLogger {
142
+ const resolvedVer = options.version ?? resolvePluginVersion({ fallback: "" });
143
+ const {
144
+ banner = true,
145
+ subsystem,
146
+ minLevel = resolveDefaultMinLevel(),
147
+ meta = {},
148
+ } = options;
149
+
150
+ const minSeverity = LEVEL_SEVERITY[minLevel];
151
+
152
+ // Prefix format: "[name vX.Y.Z]" or "[name]" or "[name vX.Y.Z:subsystem]"
153
+ const versionTag = resolvedVer ? ` v${resolvedVer}` : "";
154
+ const subTag = subsystem ? `:${subsystem}` : "";
155
+ const baseTag = `[${pluginId}${versionTag}${subTag}]`;
156
+
157
+ if (banner) {
158
+ const bannerDetails = [
159
+ `pid ${process.pid}`,
160
+ `node ${process.version}`,
161
+ ...Object.entries(meta).map(([k, v]) => `${k} ${v}`),
162
+ ].join(", ");
163
+
164
+ // Emit startup banner directly to stdout
165
+ console.log(`${baseTag} Initializing plugin (${bannerDetails})`);
166
+ }
167
+
168
+ function emit(level: LogLevel, message: string, data?: unknown) {
169
+ if (LEVEL_SEVERITY[level] < minSeverity) return;
170
+
171
+ const levelTag = `[${level.toUpperCase()}]`;
172
+ const formattedData = formatData(data);
173
+ const line = formattedData
174
+ ? `${baseTag} ${levelTag} ${message} ${formattedData}`
175
+ : `${baseTag} ${levelTag} ${message}`;
176
+
177
+ if (level === "error" || level === "warn") {
178
+ console.error(line);
179
+ } else {
180
+ console.log(line);
181
+ }
182
+ }
183
+
184
+ return {
185
+ debug(message: string, data?: unknown) {
186
+ emit("debug", message, data);
187
+ },
188
+ info(message: string, data?: unknown) {
189
+ emit("info", message, data);
190
+ },
191
+ warn(message: string, data?: unknown) {
192
+ emit("warn", message, data);
193
+ },
194
+ error(message: string, data?: unknown) {
195
+ emit("error", message, data);
196
+ },
197
+ suppressed(context: string, error: unknown) {
198
+ const detail = error instanceof Error ? error.message : String(error);
199
+ emit("debug", `${context}: ${detail}`, error);
200
+ },
201
+ child(subsystemOrOptions: string | Partial<PluginLoggerOptions>): PluginLogger {
202
+ const childOptions: PluginLoggerOptions =
203
+ typeof subsystemOrOptions === "string"
204
+ ? { ...options, version: resolvedVer, banner: false, subsystem: subsystemOrOptions }
205
+ : { ...options, version: resolvedVer, banner: false, ...subsystemOrOptions };
206
+
207
+ return createPluginLogger(pluginId, childOptions);
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,367 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { parseJsonc } from "./jsonc";
5
+
6
+ export interface McpServerConfig {
7
+ command: string;
8
+ args?: string[];
9
+ env?: Record<string, string>;
10
+ disabled?: boolean;
11
+ autoApprove?: string[];
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export interface McpConfigTarget {
16
+ /**
17
+ * Absolute or relative path to the configuration file.
18
+ * Supports '~' expansion (e.g. "~/.claude.json").
19
+ */
20
+ path: string;
21
+
22
+ /**
23
+ * The top-level key under which MCP servers are defined.
24
+ * Defaults to "mcpServers".
25
+ */
26
+ key?: string;
27
+
28
+ /**
29
+ * If true, creates a timestamped backup file before modifying an existing file.
30
+ */
31
+ backup?: boolean;
32
+ }
33
+
34
+ export interface UpsertMcpServerOptions {
35
+ /**
36
+ * Target config file path or McpConfigTarget descriptor.
37
+ */
38
+ target: string | McpConfigTarget;
39
+
40
+ /**
41
+ * Unique name of the MCP server entry (e.g. "paseo-gateway", "top", "x-comms").
42
+ */
43
+ serverName: string;
44
+
45
+ /**
46
+ * MCP server configuration object (command, args, env, etc.).
47
+ */
48
+ config: McpServerConfig;
49
+
50
+ /**
51
+ * Override backup option. If true, creates a backup before modifying.
52
+ */
53
+ backup?: boolean;
54
+ }
55
+
56
+ export interface RemoveMcpServerOptions {
57
+ /**
58
+ * Target config file path or McpConfigTarget descriptor.
59
+ */
60
+ target: string | McpConfigTarget;
61
+
62
+ /**
63
+ * Name of the MCP server entry to remove.
64
+ */
65
+ serverName: string;
66
+
67
+ /**
68
+ * Override backup option. If true, creates a backup before removing.
69
+ */
70
+ backup?: boolean;
71
+ }
72
+
73
+ export interface McpMutationResult {
74
+ filePath: string;
75
+ serverName: string;
76
+ changed: boolean;
77
+ action: "created" | "updated" | "unchanged" | "removed" | "not_found";
78
+ backupPath?: string;
79
+ }
80
+
81
+ /**
82
+ * Standard known MCP configuration paths across major developer tools and agent runners.
83
+ * Note: These are pure path resolvers with zero filesystem scanning or heuristics.
84
+ */
85
+ export const McpConfigPaths = {
86
+ /**
87
+ * Claude Desktop configuration path per operating system:
88
+ * - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
89
+ * - Windows: %APPDATA%/Claude/claude_desktop_config.json
90
+ * - Linux: ~/.config/Claude/claude_desktop_config.json
91
+ */
92
+ claudeDesktop(): string {
93
+ const platform = process.platform;
94
+ const home = os.homedir();
95
+ if (platform === "darwin") {
96
+ return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
97
+ }
98
+ if (platform === "win32") {
99
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
100
+ return path.join(appData, "Claude", "claude_desktop_config.json");
101
+ }
102
+ const configDir = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
103
+ return path.join(configDir, "Claude", "claude_desktop_config.json");
104
+ },
105
+
106
+ /**
107
+ * Claude Code CLI global configuration: ~/.claude.json
108
+ */
109
+ claudeCode(): string {
110
+ return path.join(os.homedir(), ".claude.json");
111
+ },
112
+
113
+ /**
114
+ * OpenCode configuration path: ~/.config/opencode/opencode.json
115
+ */
116
+ openCode(): string {
117
+ const home = os.homedir();
118
+ const configDir = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
119
+ return path.join(configDir, "opencode", "opencode.json");
120
+ },
121
+
122
+ /**
123
+ * Cursor editor MCP configuration: ~/.cursor/mcp.json
124
+ */
125
+ cursor(): string {
126
+ return path.join(os.homedir(), ".cursor", "mcp.json");
127
+ },
128
+
129
+ /**
130
+ * Gemini / Antigravity CLI configuration: ~/.gemini/config/mcp_config.json
131
+ */
132
+ gemini(): string {
133
+ return path.join(os.homedir(), ".gemini", "config", "mcp_config.json");
134
+ },
135
+
136
+ /**
137
+ * Pi CLI agent configuration: ~/.pi/config.json
138
+ */
139
+ pi(): string {
140
+ return path.join(os.homedir(), ".pi", "config.json");
141
+ },
142
+ };
143
+
144
+ /**
145
+ * Expands '~' to the user's home directory.
146
+ */
147
+ export function expandPath(targetPath: string): string {
148
+ if (targetPath === "~") {
149
+ return os.homedir();
150
+ }
151
+ if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) {
152
+ return path.join(os.homedir(), targetPath.slice(2));
153
+ }
154
+ return path.resolve(targetPath);
155
+ }
156
+
157
+ /**
158
+ * Deep equality check for primitives, arrays, and objects.
159
+ */
160
+ function isDeepEqual(a: unknown, b: unknown): boolean {
161
+ if (a === b) return true;
162
+ if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) {
163
+ return false;
164
+ }
165
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
166
+
167
+ if (Array.isArray(a) && Array.isArray(b)) {
168
+ if (a.length !== b.length) return false;
169
+ for (let i = 0; i < a.length; i++) {
170
+ if (!isDeepEqual(a[i], b[i])) return false;
171
+ }
172
+ return true;
173
+ }
174
+
175
+ const keysA = Object.keys(a as Record<string, unknown>);
176
+ const keysB = Object.keys(b as Record<string, unknown>);
177
+
178
+ if (keysA.length !== keysB.length) return false;
179
+ for (const key of keysA) {
180
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
181
+ if (!isDeepEqual((a as any)[key], (b as any)[key])) return false;
182
+ }
183
+ return true;
184
+ }
185
+
186
+ /**
187
+ * Normalizes target string or descriptor into { filePath, key, backup }.
188
+ */
189
+ function normalizeTarget(target: string | McpConfigTarget, backupOverride?: boolean): {
190
+ filePath: string;
191
+ key: string;
192
+ backup: boolean;
193
+ } {
194
+ if (typeof target === "string") {
195
+ return {
196
+ filePath: expandPath(target),
197
+ key: "mcpServers",
198
+ backup: Boolean(backupOverride),
199
+ };
200
+ }
201
+ return {
202
+ filePath: expandPath(target.path),
203
+ key: target.key || "mcpServers",
204
+ backup: backupOverride !== undefined ? backupOverride : Boolean(target.backup),
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Reads an existing configuration file safely using JSONC parser.
210
+ * Returns an empty object if the file does not exist.
211
+ */
212
+ function readConfigDocument(filePath: string): Record<string, any> {
213
+ if (!fs.existsSync(filePath)) {
214
+ return {};
215
+ }
216
+ const raw = fs.readFileSync(filePath, "utf8");
217
+ if (!raw.trim()) {
218
+ return {};
219
+ }
220
+ return parseJsonc<Record<string, any>>(raw);
221
+ }
222
+
223
+ /**
224
+ * Writes data atomically to the given file path using a temporary file and POSIX atomic rename.
225
+ */
226
+ function writeConfigAtomic(filePath: string, data: Record<string, any>): void {
227
+ const dir = path.dirname(filePath);
228
+ if (!fs.existsSync(dir)) {
229
+ fs.mkdirSync(dir, { recursive: true });
230
+ }
231
+
232
+ const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
233
+ const serialized = JSON.stringify(data, null, 2) + "\n";
234
+ fs.writeFileSync(tempPath, serialized, "utf8");
235
+ fs.renameSync(tempPath, filePath);
236
+ }
237
+
238
+ /**
239
+ * Retrieves an MCP server definition from a config file.
240
+ * Returns null if the file, key, or server does not exist.
241
+ */
242
+ export function getMcpServer(
243
+ target: string | McpConfigTarget,
244
+ serverName: string
245
+ ): McpServerConfig | null {
246
+ const { filePath, key } = normalizeTarget(target);
247
+ if (!fs.existsSync(filePath)) {
248
+ return null;
249
+ }
250
+ try {
251
+ const doc = readConfigDocument(filePath);
252
+ const servers = doc[key];
253
+ if (servers && typeof servers === "object" && serverName in servers) {
254
+ return servers[serverName] as McpServerConfig;
255
+ }
256
+ return null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Upserts an MCP server definition into an agent or tool configuration file.
264
+ *
265
+ * Guarantees:
266
+ * 1. Namespaced idempotency: If the server configuration already exists and matches,
267
+ * no write occurs, preventing file watcher thrashing.
268
+ * 2. JSONC safe: Reads files containing comments or trailing commas without crashing.
269
+ * 3. Atomic writes: Uses temporary files and atomic rename to prevent corruption.
270
+ * 4. Optional backups: Can create a timestamped backup before modifying existing files.
271
+ */
272
+ export function upsertMcpServer(options: UpsertMcpServerOptions): McpMutationResult {
273
+ const { filePath, key, backup } = normalizeTarget(options.target, options.backup);
274
+ const { serverName, config } = options;
275
+
276
+ let doc: Record<string, any>;
277
+ const fileExisted = fs.existsSync(filePath);
278
+
279
+ if (fileExisted) {
280
+ doc = readConfigDocument(filePath);
281
+ } else {
282
+ doc = {};
283
+ }
284
+
285
+ if (typeof doc[key] !== "object" || doc[key] === null || Array.isArray(doc[key])) {
286
+ doc[key] = {};
287
+ }
288
+
289
+ const existingConfig = doc[key][serverName];
290
+ const isExisting = existingConfig !== undefined;
291
+
292
+ // Idempotency: Deep equality check
293
+ if (isExisting && isDeepEqual(existingConfig, config)) {
294
+ return {
295
+ filePath,
296
+ serverName,
297
+ changed: false,
298
+ action: "unchanged",
299
+ };
300
+ }
301
+
302
+ let backupPath: string | undefined;
303
+ if (backup && fileExisted) {
304
+ backupPath = `${filePath}.bak.${Date.now()}`;
305
+ fs.copyFileSync(filePath, backupPath);
306
+ }
307
+
308
+ doc[key][serverName] = config;
309
+ writeConfigAtomic(filePath, doc);
310
+
311
+ return {
312
+ filePath,
313
+ serverName,
314
+ changed: true,
315
+ action: isExisting ? "updated" : "created",
316
+ backupPath,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Removes an MCP server definition from a config file.
322
+ *
323
+ * Guarantees:
324
+ * 1. Safe no-op: If the file or server does not exist, no write occurs.
325
+ * 2. Atomic writes: Cleanly removes the key and updates the file atomically.
326
+ * 3. Optional backups: Can create a timestamped backup before removal.
327
+ */
328
+ export function removeMcpServer(options: RemoveMcpServerOptions): McpMutationResult {
329
+ const { filePath, key, backup } = normalizeTarget(options.target, options.backup);
330
+ const { serverName } = options;
331
+
332
+ if (!fs.existsSync(filePath)) {
333
+ return {
334
+ filePath,
335
+ serverName,
336
+ changed: false,
337
+ action: "not_found",
338
+ };
339
+ }
340
+
341
+ const doc = readConfigDocument(filePath);
342
+ if (!doc[key] || typeof doc[key] !== "object" || !(serverName in doc[key])) {
343
+ return {
344
+ filePath,
345
+ serverName,
346
+ changed: false,
347
+ action: "not_found",
348
+ };
349
+ }
350
+
351
+ let backupPath: string | undefined;
352
+ if (backup) {
353
+ backupPath = `${filePath}.bak.${Date.now()}`;
354
+ fs.copyFileSync(filePath, backupPath);
355
+ }
356
+
357
+ delete doc[key][serverName];
358
+ writeConfigAtomic(filePath, doc);
359
+
360
+ return {
361
+ filePath,
362
+ serverName,
363
+ changed: true,
364
+ action: "removed",
365
+ backupPath,
366
+ };
367
+ }
@@ -0,0 +1,85 @@
1
+ export interface McpStdioInjectionConfig {
2
+ type: "stdio";
3
+ command: string;
4
+ args?: string[];
5
+ env?: Record<string, string>;
6
+ alwaysLoad?: boolean;
7
+ }
8
+
9
+ export interface McpHttpInjectionConfig {
10
+ type: "http";
11
+ url: string;
12
+ headers?: Record<string, string>;
13
+ alwaysLoad?: boolean;
14
+ }
15
+
16
+ export interface McpSseInjectionConfig {
17
+ type: "sse";
18
+ url: string;
19
+ headers?: Record<string, string>;
20
+ alwaysLoad?: boolean;
21
+ }
22
+
23
+ export type McpInjectionConfig =
24
+ | McpStdioInjectionConfig
25
+ | McpHttpInjectionConfig
26
+ | McpSseInjectionConfig;
27
+
28
+ export interface AgentCreateInjectionConfig {
29
+ mcpServers?: Record<string, McpInjectionConfig>;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ export interface AgentCreateInjectionRequest {
34
+ config: AgentCreateInjectionConfig;
35
+ env?: Record<string, string>;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export type McpInjectionFilter = (input: {
40
+ request: AgentCreateInjectionRequest;
41
+ }) => boolean;
42
+
43
+ export type McpInjectionHookHandler = (
44
+ input: { request: AgentCreateInjectionRequest },
45
+ context?: unknown,
46
+ ) => AgentCreateInjectionRequest | void | Promise<AgentCreateInjectionRequest | void>;
47
+
48
+ export interface McpInjectionServer {
49
+ // Loose on purpose: the real SDK declares a generic
50
+ // before<Name extends keyof PluginBeforeRequests>(...) whose name param is
51
+ // narrower than string and whose handler uses SDK request types. Typing this
52
+ // boundary with any keeps the SDK object directly assignable with no
53
+ // consumer-side adapter and no SDK imports here. Handler authors should use
54
+ // McpInjectionHookHandler for the precise shape.
55
+ before(
56
+ name: string,
57
+ handler: (input: { request: any }, context?: any) => any,
58
+ ): () => void;
59
+ }
60
+
61
+ export interface RegisterMcpInjectionOptions {
62
+ serverName: string;
63
+ config: McpInjectionConfig;
64
+ filter?: McpInjectionFilter;
65
+ }
66
+
67
+ export function registerMcpInjection(
68
+ server: McpInjectionServer,
69
+ options: RegisterMcpInjectionOptions,
70
+ ): () => void {
71
+ const { serverName, config, filter } = options;
72
+ return server.before("agent.create", ({ request }: { request: AgentCreateInjectionRequest }) => {
73
+ if (filter && !filter({ request })) return;
74
+ return {
75
+ ...request,
76
+ config: {
77
+ ...request.config,
78
+ mcpServers: {
79
+ ...(request.config.mcpServers ?? {}),
80
+ [serverName]: config,
81
+ },
82
+ },
83
+ };
84
+ });
85
+ }