@xpufx/paseo-plugin-updates 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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/client/orphans.ts +37 -0
- package/client/updates.tsx +510 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/package.json +37 -0
- package/paseo-plugin.json +4 -0
- package/server/__node-test-shim.ts +14 -0
- package/server/updates.ts +1332 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/updates.ts +239 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -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
|
+
}
|