@xmemo/client 0.4.166 → 0.4.169
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/README.md +21 -0
- package/package.json +2 -2
- package/src/cli.js +5 -0
- package/src/commands/setup.js +38 -34
- package/src/commands/uninstall.js +238 -0
- package/src/mcp/clients/registry.js +8 -4
- package/src/mcp/clients/scan.js +109 -0
- package/src/mcp/clients.js +9 -3
- package/src/mcp/formats/json.js +95 -6
- package/src/mcp/formats/toml.js +56 -8
- package/src/mcp/formats/yaml.js +108 -2
- package/src/mcp/proxy/copilot.js +39 -4
- package/src/ui/help.js +12 -2
- package/src/ui/setup.js +10 -0
- package/src/ui/uninstall.js +92 -0
package/README.md
CHANGED
|
@@ -193,6 +193,27 @@ configure MCP only. Use `--dry-run` to preview without writing config or profile
|
|
|
193
193
|
files, and `--profile-target <path>` to choose a different behavior profile
|
|
194
194
|
target.
|
|
195
195
|
|
|
196
|
+
### Uninstall
|
|
197
|
+
|
|
198
|
+
Remove the XMemo MCP server entry from one or all detected client configs:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
xmemo uninstall --all --dry-run
|
|
202
|
+
xmemo uninstall --all --yes
|
|
203
|
+
xmemo uninstall cursor --yes
|
|
204
|
+
xmemo uninstall --all --yes --profiles
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`xmemo uninstall --all` scans the same clients as `setup --all` and removes only
|
|
208
|
+
the `XMemo` entry (and legacy names such as `memory_os`) from each detected
|
|
209
|
+
config file. Other MCP servers are preserved. By default it shows a summary and
|
|
210
|
+
asks for confirmation; pass `--yes` (or `-y`) to skip the prompt, or `--dry-run`
|
|
211
|
+
to preview without modifying files.
|
|
212
|
+
|
|
213
|
+
Pass `--profiles` to also remove installed behavior profiles (Codex `AGENTS.md`,
|
|
214
|
+
Cursor memory profile, etc.). Identity files and credentials are not removed, so
|
|
215
|
+
a later `xmemo setup --all` can re-enable XMemo with the same agent instance ID.
|
|
216
|
+
|
|
196
217
|
Default behavior profile targets:
|
|
197
218
|
|
|
198
219
|
```text
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmemo/client",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.169",
|
|
4
4
|
"description": "Privacy-first CLI and MCP setup helper for XMemo.",
|
|
5
5
|
"mcpName": "io.github.yonro/xmemo",
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"lint": "node scripts/check-js.mjs",
|
|
20
|
-
"test": "node --test",
|
|
20
|
+
"test": "node --test \"test/**/*.test.js\"",
|
|
21
21
|
"pack:dry-run": "npm pack --dry-run",
|
|
22
22
|
"prepublishOnly": "npm run lint && npm test && npm run pack:dry-run"
|
|
23
23
|
},
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { mcpCommand } from './commands/mcp.js';
|
|
17
17
|
import { profileCommand } from './commands/profile.js';
|
|
18
18
|
import { setupCommand } from './commands/setup.js';
|
|
19
|
+
import { uninstallCommand } from './commands/uninstall.js';
|
|
19
20
|
import { updateCommand } from './commands/update.js';
|
|
20
21
|
import { envCommand, writePrivacy } from './config/env.js';
|
|
21
22
|
import { UsageError } from './core/errors.js';
|
|
@@ -56,6 +57,10 @@ export async function run(args, io = defaultIo()) {
|
|
|
56
57
|
return await setupCommand(args.slice(1), io);
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
if (command === 'uninstall') {
|
|
61
|
+
return await uninstallCommand(args.slice(1), io);
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
if (command === 'login') {
|
|
60
65
|
return await loginCommand(args.slice(1), io);
|
|
61
66
|
}
|
package/src/commands/setup.js
CHANGED
|
@@ -8,6 +8,10 @@ import { baseUrlOption } from '../network/base-url.js';
|
|
|
8
8
|
import {
|
|
9
9
|
DEFAULT_PROXY_PORT
|
|
10
10
|
} from '../core/constants.js';
|
|
11
|
+
import {
|
|
12
|
+
autoScanClientIds,
|
|
13
|
+
detectedSetupTargets
|
|
14
|
+
} from '../mcp/clients/scan.js';
|
|
11
15
|
import {
|
|
12
16
|
buildSetupPlan,
|
|
13
17
|
ensureDiscoveryService
|
|
@@ -24,7 +28,7 @@ import {
|
|
|
24
28
|
supportedMcpClients
|
|
25
29
|
} from '../mcp/clients.js';
|
|
26
30
|
import { mergeCopilotMcpConfig } from '../mcp/proxy/copilot.js';
|
|
27
|
-
|
|
31
|
+
|
|
28
32
|
import {
|
|
29
33
|
agentIdentity,
|
|
30
34
|
envReferenceIdentity
|
|
@@ -66,6 +70,7 @@ export async function setupCommand(args, io) {
|
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
const dryRun = hasFlag(optionArgs, '--dry-run') || hasFlag(optionArgs, '--preview');
|
|
73
|
+
const force = hasFlag(optionArgs, '--force');
|
|
69
74
|
const writeConfig = !dryRun && (hasFlag(optionArgs, '--write') || hasFlag(optionArgs, '--yes') || shortClientSetup || (setupAll && (hasFlag(optionArgs, '--write') || hasFlag(optionArgs, '--yes'))));
|
|
70
75
|
const timeoutMs = parsePositiveInteger(optionValue(optionArgs, '--timeout-ms') ?? '5000', '--timeout-ms');
|
|
71
76
|
|
|
@@ -92,49 +97,48 @@ export async function setupCommand(args, io) {
|
|
|
92
97
|
|
|
93
98
|
if (setupAll) {
|
|
94
99
|
setupPlan.detectedClients = [];
|
|
95
|
-
const scanIds =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
100
|
+
const scanIds = autoScanClientIds(MCP_CLIENTS);
|
|
101
|
+
const targets = await detectedSetupTargets(scanIds, io.env, MCP_CLIENTS);
|
|
102
|
+
for (const target of targets) {
|
|
103
|
+
const scanId = target.clientId;
|
|
104
|
+
let clientPlan;
|
|
105
|
+
if (scanId === 'copilot-cli') {
|
|
106
|
+
const proxyPort = parsePositiveInteger(optionValue(optionArgs, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
|
|
107
|
+
clientPlan = copilotSetupPlan(setupPlan.mcpUrl, proxyPort, io.env);
|
|
108
|
+
clientPlan.configPath = target.configPath;
|
|
109
|
+
if (writeConfig) {
|
|
110
|
+
await mergeCopilotMcpConfig(clientPlan.configPath, clientPlan.proxyUrl, force);
|
|
111
|
+
clientPlan.written = true;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const client = MCP_CLIENTS.get(scanId);
|
|
115
|
+
const identity = writeConfig ? await agentIdentity(scanId, io.env) : envReferenceIdentity(scanId);
|
|
116
|
+
clientPlan = clientSetupPlan(scanId, client, setupPlan.mcpUrl, io.env, identity);
|
|
117
|
+
clientPlan.configPath = target.configPath;
|
|
118
|
+
if (writeConfig) {
|
|
119
|
+
await client.writeConfig(clientPlan.configPath, setupPlan.mcpUrl, identity, { force });
|
|
120
|
+
clientPlan.written = true;
|
|
121
|
+
if (profileClientConfig(scanId)) {
|
|
122
|
+
const installProfile = hasFlag(optionArgs, '--yes') || hasFlag(optionArgs, '--profile');
|
|
123
|
+
if (installProfile) {
|
|
124
|
+
const profileTarget = defaultProfileTarget(scanId, io.env);
|
|
125
|
+
const profileResult = await profileInstallResult(scanId, profileTarget, { write: true });
|
|
126
|
+
clientPlan.behaviorProfile = profileResult;
|
|
127
|
+
if (scanId === 'codex') {
|
|
128
|
+
clientPlan.codexProfile = profileResult;
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
|
-
setupPlan.detectedClients.push(clientPlan);
|
|
130
133
|
}
|
|
134
|
+
setupPlan.detectedClients.push(clientPlan);
|
|
131
135
|
}
|
|
132
136
|
} else if (clientId) {
|
|
133
137
|
if (clientId === 'copilot-cli') {
|
|
134
138
|
const proxyPort = parsePositiveInteger(optionValue(optionArgs, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
|
|
135
139
|
setupPlan.selectedClient = copilotSetupPlan(setupPlan.mcpUrl, proxyPort, io.env);
|
|
136
140
|
if (writeConfig) {
|
|
137
|
-
await mergeCopilotMcpConfig(setupPlan.selectedClient.configPath, setupPlan.selectedClient.proxyUrl);
|
|
141
|
+
await mergeCopilotMcpConfig(setupPlan.selectedClient.configPath, setupPlan.selectedClient.proxyUrl, force);
|
|
138
142
|
setupPlan.selectedClient.written = true;
|
|
139
143
|
}
|
|
140
144
|
} else {
|
|
@@ -146,7 +150,7 @@ export async function setupCommand(args, io) {
|
|
|
146
150
|
const identity = writeConfig ? await agentIdentity(clientId, io.env) : envReferenceIdentity(clientId);
|
|
147
151
|
setupPlan.selectedClient = clientSetupPlan(clientId, client, setupPlan.mcpUrl, io.env, identity);
|
|
148
152
|
if (writeConfig) {
|
|
149
|
-
await client.writeConfig(setupPlan.selectedClient.configPath, setupPlan.mcpUrl, identity);
|
|
153
|
+
await client.writeConfig(setupPlan.selectedClient.configPath, setupPlan.mcpUrl, identity, { force });
|
|
150
154
|
setupPlan.selectedClient.written = true;
|
|
151
155
|
}
|
|
152
156
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { hasFlag, optionValue } from '../core/args.js';
|
|
2
|
+
import {
|
|
3
|
+
MCP_SERVER_NAME
|
|
4
|
+
} from '../core/constants.js';
|
|
5
|
+
import { UsageError } from '../core/errors.js';
|
|
6
|
+
import { writeLine } from '../core/io.js';
|
|
7
|
+
import { MCP_CLIENTS } from '../mcp/clients.js';
|
|
8
|
+
import {
|
|
9
|
+
autoScanClientIds,
|
|
10
|
+
existingUninstallTargets
|
|
11
|
+
} from '../mcp/clients/scan.js';
|
|
12
|
+
import { removeCopilotMcpConfig } from '../mcp/proxy/copilot.js';
|
|
13
|
+
import {
|
|
14
|
+
profileClientConfig,
|
|
15
|
+
profileUninstallResult
|
|
16
|
+
} from '../config/profile.js';
|
|
17
|
+
import {
|
|
18
|
+
normalizeSetupClientId,
|
|
19
|
+
positionalClientArg,
|
|
20
|
+
supportedSetupClientIds
|
|
21
|
+
} from '../ui/setup.js';
|
|
22
|
+
import { confirmUninstall, writeUninstallSummary } from '../ui/uninstall.js';
|
|
23
|
+
|
|
24
|
+
export async function uninstallCommand(args, io) {
|
|
25
|
+
const positionalClientId = positionalClientArg(args, MCP_CLIENTS);
|
|
26
|
+
const optionArgs = positionalClientId ? args.slice(1) : args;
|
|
27
|
+
const uninstallAll = hasFlag(optionArgs, '--all');
|
|
28
|
+
const outputJson = hasFlag(optionArgs, '--json');
|
|
29
|
+
|
|
30
|
+
let clientId = null;
|
|
31
|
+
try {
|
|
32
|
+
clientId = normalizeSetupClientId(positionalClientId ?? optionValue(optionArgs, '--client'), MCP_CLIENTS);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (!uninstallAll) {
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (uninstallAll && clientId) {
|
|
40
|
+
throw new UsageError('Cannot specify both --all and a specific client.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!uninstallAll && !clientId) {
|
|
44
|
+
throw new UsageError(`Uninstall requires --all, --client <${supportedSetupClientIds(MCP_CLIENTS).join('|')}>, or a positional client id.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const dryRun = hasFlag(optionArgs, '--dry-run') || hasFlag(optionArgs, '--preview');
|
|
48
|
+
const skipConfirm = hasFlag(optionArgs, '--yes') || hasFlag(optionArgs, '-y');
|
|
49
|
+
const removeProfiles = hasFlag(optionArgs, '--profiles');
|
|
50
|
+
const profileTargetOverride = optionValue(optionArgs, '--target') ?? optionValue(optionArgs, '--profile-target');
|
|
51
|
+
|
|
52
|
+
if (uninstallAll && profileTargetOverride) {
|
|
53
|
+
throw new UsageError('Cannot specify --target or --profile-target with --all.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const targetIds = uninstallAll ? autoScanClientIds(MCP_CLIENTS) : [clientId];
|
|
57
|
+
let targets = await existingUninstallTargets(targetIds, io.env, MCP_CLIENTS);
|
|
58
|
+
|
|
59
|
+
if (targets.length === 0 && removeProfiles && !uninstallAll) {
|
|
60
|
+
const client = MCP_CLIENTS.get(clientId);
|
|
61
|
+
targets.push({
|
|
62
|
+
clientId,
|
|
63
|
+
label: client?.label ?? clientId,
|
|
64
|
+
configPath: null,
|
|
65
|
+
configKind: 'none'
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const previewOptions = {
|
|
70
|
+
removeProfiles,
|
|
71
|
+
profileTargetOverride,
|
|
72
|
+
preview: true
|
|
73
|
+
};
|
|
74
|
+
const plan = await buildUninstallPlan(targets, io, previewOptions);
|
|
75
|
+
|
|
76
|
+
if (dryRun || (outputJson && !skipConfirm)) {
|
|
77
|
+
if (outputJson) {
|
|
78
|
+
writeLine(io.stdout, JSON.stringify(plan, null, 2));
|
|
79
|
+
} else {
|
|
80
|
+
writeUninstallSummary(plan, io);
|
|
81
|
+
}
|
|
82
|
+
return plan.errors.length > 0 ? 1 : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (plan.removed.length === 0 && plan.errors.length === 0) {
|
|
86
|
+
if (outputJson) {
|
|
87
|
+
writeLine(io.stdout, JSON.stringify(plan, null, 2));
|
|
88
|
+
} else {
|
|
89
|
+
writeUninstallSummary(plan, io);
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!outputJson) {
|
|
95
|
+
writeUninstallSummary(plan, io);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!skipConfirm) {
|
|
99
|
+
if (io.stdin.isTTY === false) {
|
|
100
|
+
throw new UsageError('Uninstall requires --yes or --dry-run when stdin is not interactive.');
|
|
101
|
+
}
|
|
102
|
+
const confirmed = await confirmUninstall(plan, io);
|
|
103
|
+
if (!confirmed) {
|
|
104
|
+
writeLine(io.stdout, 'Uninstall cancelled.');
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = await buildUninstallPlan(targets, io, {
|
|
110
|
+
removeProfiles,
|
|
111
|
+
profileTargetOverride,
|
|
112
|
+
preview: false
|
|
113
|
+
});
|
|
114
|
+
if (outputJson) {
|
|
115
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
116
|
+
} else {
|
|
117
|
+
writeUninstallSummary(result, io);
|
|
118
|
+
}
|
|
119
|
+
return result.errors.length > 0 ? 1 : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function buildUninstallPlan(targets, io, options) {
|
|
123
|
+
const plan = {
|
|
124
|
+
dryRun: options.preview,
|
|
125
|
+
write: !options.preview,
|
|
126
|
+
profiles: options.removeProfiles,
|
|
127
|
+
removed: [],
|
|
128
|
+
skipped: [],
|
|
129
|
+
errors: []
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
for (const target of targets) {
|
|
133
|
+
const configResult = await removeConfigForTarget(target, options.preview);
|
|
134
|
+
const entry = {
|
|
135
|
+
id: target.clientId,
|
|
136
|
+
label: target.label,
|
|
137
|
+
configPath: target.configPath,
|
|
138
|
+
configStatus: configResult.status,
|
|
139
|
+
removedNames: configResult.removedNames ?? []
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (configResult.status === 'removed') {
|
|
143
|
+
entry.configChanged = true;
|
|
144
|
+
} else if (configResult.status === 'not_found' || configResult.status === 'missing') {
|
|
145
|
+
entry.configChanged = false;
|
|
146
|
+
} else if (configResult.status === 'error') {
|
|
147
|
+
entry.error = configResult.error;
|
|
148
|
+
plan.errors.push({
|
|
149
|
+
id: target.clientId,
|
|
150
|
+
label: target.label,
|
|
151
|
+
configPath: target.configPath,
|
|
152
|
+
phase: 'config',
|
|
153
|
+
error: configResult.error
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (options.removeProfiles) {
|
|
158
|
+
const profileResult = await removeProfileForTarget(target, io.env, options);
|
|
159
|
+
entry.profilePath = profileResult.targetPath;
|
|
160
|
+
entry.profileStatus = profileResult.status;
|
|
161
|
+
if (profileResult.status === 'removed') {
|
|
162
|
+
entry.profileChanged = true;
|
|
163
|
+
} else if (profileResult.status === 'not_found') {
|
|
164
|
+
entry.profileChanged = false;
|
|
165
|
+
} else if (profileResult.status === 'error') {
|
|
166
|
+
entry.profileError = profileResult.error;
|
|
167
|
+
plan.errors.push({
|
|
168
|
+
id: target.clientId,
|
|
169
|
+
label: target.label,
|
|
170
|
+
profilePath: profileResult.targetPath,
|
|
171
|
+
phase: 'profile',
|
|
172
|
+
error: profileResult.error
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (configResult.status === 'removed' ||
|
|
178
|
+
(options.removeProfiles && entry.profileStatus === 'removed')) {
|
|
179
|
+
plan.removed.push(entry);
|
|
180
|
+
} else if (configResult.status === 'not_found' || configResult.status === 'missing') {
|
|
181
|
+
if (!options.removeProfiles || entry.profileStatus !== 'removed') {
|
|
182
|
+
plan.skipped.push(entry);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return plan;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function removeConfigForTarget(target, preview) {
|
|
191
|
+
if (!target.configPath) {
|
|
192
|
+
return { status: 'not_found', reason: 'no_config_path' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
let result;
|
|
197
|
+
if (target.clientId === 'copilot-cli') {
|
|
198
|
+
result = await removeCopilotMcpConfig(target.configPath, { preview });
|
|
199
|
+
} else {
|
|
200
|
+
const client = MCP_CLIENTS.get(target.clientId);
|
|
201
|
+
if (!client || !client.removeConfig) {
|
|
202
|
+
return { status: 'not_found', reason: 'unsupported' };
|
|
203
|
+
}
|
|
204
|
+
result = await client.removeConfig(target.configPath, { preview });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (result.removed) {
|
|
208
|
+
return { status: 'removed', removedNames: result.removedNames ?? [MCP_SERVER_NAME] };
|
|
209
|
+
}
|
|
210
|
+
if (result.reason === 'manual-edit-required') {
|
|
211
|
+
return { status: 'error', error: 'Config shape requires manual edit to remove XMemo safely.' };
|
|
212
|
+
}
|
|
213
|
+
return { status: result.reason ?? 'not_found' };
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return { status: 'error', error: error.message };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function removeProfileForTarget(target, env, options) {
|
|
220
|
+
const profileConfig = profileClientConfig(target.clientId);
|
|
221
|
+
if (!profileConfig) {
|
|
222
|
+
return { status: 'not_found', reason: 'unsupported', targetPath: null };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const targetPath = options.profileTargetOverride
|
|
226
|
+
? options.profileTargetOverride
|
|
227
|
+
: profileConfig.defaultTarget(env);
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const result = await profileUninstallResult(target.clientId, targetPath, { write: !options.preview });
|
|
231
|
+
if (result.changed) {
|
|
232
|
+
return { status: 'removed', targetPath: result.targetPath };
|
|
233
|
+
}
|
|
234
|
+
return { status: 'not_found', targetPath: result.targetPath };
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return { status: 'error', targetPath, error: error.message };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -7,7 +7,8 @@ export function createMcpClients(deps) {
|
|
|
7
7
|
label: 'Codex',
|
|
8
8
|
defaultConfigPath: deps.defaultCodexConfigPath,
|
|
9
9
|
buildSnippet: deps.codexTomlSnippet,
|
|
10
|
-
writeConfig: deps.appendTomlServerConfig,
|
|
10
|
+
writeConfig: (configPath, mcpUrl, identity, options = {}) => deps.appendTomlServerConfig(configPath, mcpUrl, identity, options.force),
|
|
11
|
+
removeConfig: (configPath, options = {}) => deps.removeTomlServerConfig(configPath, options),
|
|
11
12
|
configKind: 'toml'
|
|
12
13
|
});
|
|
13
14
|
|
|
@@ -15,7 +16,8 @@ export function createMcpClients(deps) {
|
|
|
15
16
|
label: 'Grok',
|
|
16
17
|
defaultConfigPath: deps.defaultGrokConfigPath,
|
|
17
18
|
buildSnippet: deps.grokTomlSnippet,
|
|
18
|
-
writeConfig: deps.appendGrokServerConfig,
|
|
19
|
+
writeConfig: (configPath, mcpUrl, identity, options = {}) => deps.appendGrokServerConfig(configPath, mcpUrl, identity, options.force),
|
|
20
|
+
removeConfig: (configPath, options = {}) => deps.removeTomlServerConfig(configPath, options),
|
|
19
21
|
configKind: 'toml'
|
|
20
22
|
});
|
|
21
23
|
|
|
@@ -38,7 +40,8 @@ function jsonClient(definition, deps) {
|
|
|
38
40
|
label: definition.label,
|
|
39
41
|
defaultConfigPath: deps[definition.defaultConfigPath],
|
|
40
42
|
buildSnippet: (mcpUrl, identity) => deps.jsonClientSnippet(definition.id, mcpUrl, identity),
|
|
41
|
-
writeConfig: (configPath, mcpUrl, identity) => deps.mergeJsonClientMcpConfig(definition.id, configPath, mcpUrl, identity),
|
|
43
|
+
writeConfig: (configPath, mcpUrl, identity, options = {}) => deps.mergeJsonClientMcpConfig(definition.id, configPath, mcpUrl, identity, options.force),
|
|
44
|
+
removeConfig: (configPath, options = {}) => deps.removeJsonClientMcpConfig(definition.id, configPath, options),
|
|
42
45
|
configKind: definition.configKind,
|
|
43
46
|
authentication: definition.authentication
|
|
44
47
|
};
|
|
@@ -49,7 +52,8 @@ function hermesClient(deps) {
|
|
|
49
52
|
label: 'Hermes',
|
|
50
53
|
defaultConfigPath: deps.defaultHermesConfigPath,
|
|
51
54
|
buildSnippet: deps.hermesYamlSnippet,
|
|
52
|
-
writeConfig: deps.mergeHermesMcpConfig,
|
|
55
|
+
writeConfig: (configPath, mcpUrl, identity, options = {}) => deps.mergeHermesMcpConfig(configPath, mcpUrl, identity, options.force),
|
|
56
|
+
removeConfig: (configPath, options = {}) => deps.removeHermesMcpConfig(configPath, options),
|
|
53
57
|
configKind: 'yaml'
|
|
54
58
|
};
|
|
55
59
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { fileExists } from '../../core/runtime.js';
|
|
5
|
+
import { defaultCopilotConfigPath } from '../identity/paths.js';
|
|
6
|
+
import { supportedMcpClientIds } from './registry.js';
|
|
7
|
+
|
|
8
|
+
export function autoScanClientIds(mcpClients) {
|
|
9
|
+
return [...supportedMcpClientIds(mcpClients), 'copilot-cli'];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function clientConfigPathCandidates(clientId, env, mcpClients) {
|
|
13
|
+
const candidates = [];
|
|
14
|
+
|
|
15
|
+
if (clientId === 'copilot-cli' || clientId === 'copilot') {
|
|
16
|
+
if (process.platform === 'win32' && env.APPDATA) {
|
|
17
|
+
candidates.push(path.join(env.APPDATA, 'Code', 'User', 'mcp.json'));
|
|
18
|
+
} else {
|
|
19
|
+
const home = env.HOME || os.homedir();
|
|
20
|
+
if (process.platform === 'darwin') {
|
|
21
|
+
candidates.push(path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'));
|
|
22
|
+
}
|
|
23
|
+
candidates.push(path.join(home, '.config', 'Code', 'User', 'mcp.json'));
|
|
24
|
+
}
|
|
25
|
+
candidates.push(defaultCopilotConfigPath(env));
|
|
26
|
+
return candidates;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (clientId === 'cline') {
|
|
30
|
+
if (process.platform === 'win32' && env.APPDATA) {
|
|
31
|
+
candidates.push(path.join(env.APPDATA, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'));
|
|
32
|
+
} else {
|
|
33
|
+
const home = env.HOME || os.homedir();
|
|
34
|
+
if (process.platform === 'darwin') {
|
|
35
|
+
candidates.push(path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'));
|
|
36
|
+
}
|
|
37
|
+
candidates.push(path.join(home, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const client = mcpClients.get(clientId);
|
|
42
|
+
if (client) {
|
|
43
|
+
candidates.push(client.defaultConfigPath(env));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return candidates;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function detectedSetupTargets(clientIds, env, mcpClients) {
|
|
50
|
+
const targets = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
|
|
53
|
+
for (const clientId of clientIds) {
|
|
54
|
+
const candidates = clientConfigPathCandidates(clientId, env, mcpClients);
|
|
55
|
+
for (const configPath of candidates) {
|
|
56
|
+
const resolved = path.resolve(configPath);
|
|
57
|
+
if (seen.has(resolved)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (await fileExists(configPath) || await fileExists(path.dirname(configPath))) {
|
|
61
|
+
seen.add(resolved);
|
|
62
|
+
targets.push(buildTarget(clientId, resolved, mcpClients));
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return targets;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function existingUninstallTargets(clientIds, env, mcpClients) {
|
|
72
|
+
const targets = [];
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
|
|
75
|
+
for (const clientId of clientIds) {
|
|
76
|
+
const candidates = clientConfigPathCandidates(clientId, env, mcpClients);
|
|
77
|
+
for (const configPath of candidates) {
|
|
78
|
+
const resolved = path.resolve(configPath);
|
|
79
|
+
if (seen.has(resolved)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (await fileExists(configPath)) {
|
|
83
|
+
seen.add(resolved);
|
|
84
|
+
targets.push(buildTarget(clientId, resolved, mcpClients));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return targets;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildTarget(clientId, configPath, mcpClients) {
|
|
93
|
+
if (clientId === 'copilot-cli') {
|
|
94
|
+
return {
|
|
95
|
+
clientId,
|
|
96
|
+
label: 'Copilot CLI',
|
|
97
|
+
configPath,
|
|
98
|
+
configKind: 'local-proxy'
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const client = mcpClients.get(clientId);
|
|
103
|
+
return {
|
|
104
|
+
clientId,
|
|
105
|
+
label: client?.label ?? clientId,
|
|
106
|
+
configPath,
|
|
107
|
+
configKind: client?.configKind ?? 'json'
|
|
108
|
+
};
|
|
109
|
+
}
|
package/src/mcp/clients.js
CHANGED
|
@@ -2,16 +2,19 @@ import {
|
|
|
2
2
|
appendTomlServerConfig,
|
|
3
3
|
appendGrokServerConfig,
|
|
4
4
|
codexTomlSnippet,
|
|
5
|
-
grokTomlSnippet
|
|
5
|
+
grokTomlSnippet,
|
|
6
|
+
removeTomlServerConfig
|
|
6
7
|
} from './formats/toml.js';
|
|
7
8
|
import {
|
|
8
9
|
hermesYamlSnippet,
|
|
9
|
-
mergeHermesMcpConfig
|
|
10
|
+
mergeHermesMcpConfig,
|
|
11
|
+
removeHermesMcpConfig
|
|
10
12
|
} from './formats/yaml.js';
|
|
11
13
|
import {
|
|
12
14
|
JSON_MCP_CLIENT_DEFINITIONS,
|
|
13
15
|
jsonClientSnippet,
|
|
14
|
-
mergeJsonClientMcpConfig
|
|
16
|
+
mergeJsonClientMcpConfig,
|
|
17
|
+
removeJsonClientMcpConfig
|
|
15
18
|
} from './formats/json.js';
|
|
16
19
|
import {
|
|
17
20
|
defaultAntigravity2ConfigPath,
|
|
@@ -49,14 +52,17 @@ export const MCP_CLIENTS = createMcpClients({
|
|
|
49
52
|
defaultCodexConfigPath,
|
|
50
53
|
codexTomlSnippet,
|
|
51
54
|
appendTomlServerConfig,
|
|
55
|
+
removeTomlServerConfig,
|
|
52
56
|
defaultGrokConfigPath,
|
|
53
57
|
grokTomlSnippet,
|
|
54
58
|
appendGrokServerConfig,
|
|
55
59
|
defaultHermesConfigPath,
|
|
56
60
|
hermesYamlSnippet,
|
|
57
61
|
mergeHermesMcpConfig,
|
|
62
|
+
removeHermesMcpConfig,
|
|
58
63
|
jsonClientSnippet,
|
|
59
64
|
mergeJsonClientMcpConfig,
|
|
65
|
+
removeJsonClientMcpConfig,
|
|
60
66
|
defaultCursorConfigPath,
|
|
61
67
|
defaultGeminiConfigPath,
|
|
62
68
|
defaultAntigravityConfigPath,
|
package/src/mcp/formats/json.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
readTextIfExists
|
|
17
17
|
} from '../../core/runtime.js';
|
|
18
18
|
import { envReferenceIdentity } from '../identity/device.js';
|
|
19
|
-
import { existingJsonMcpServerName } from '../core/names.js';
|
|
19
|
+
import { existingJsonMcpServerName, knownMcpServerNames } from '../core/names.js';
|
|
20
20
|
|
|
21
21
|
export const JSON_MCP_CLIENT_DEFINITIONS = Object.freeze([
|
|
22
22
|
httpClientDefinition('cursor', 'Cursor', 'defaultCursorConfigPath', { urlKey: 'url', authentication: 'env-bearer' }),
|
|
@@ -118,14 +118,14 @@ export function jsonClientServerConfig(clientId, mcpUrl, identity) {
|
|
|
118
118
|
return serverConfigFromDefinition(definition, mcpUrl, resolvedIdentity);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
export async function mergeJsonClientMcpConfig(clientId, configPath, mcpUrl, identity) {
|
|
121
|
+
export async function mergeJsonClientMcpConfig(clientId, configPath, mcpUrl, identity, force = false) {
|
|
122
122
|
const definition = requireJsonMcpClientDefinition(clientId);
|
|
123
123
|
const serverConfig = serverConfigFromDefinition(definition, mcpUrl, identity);
|
|
124
124
|
await mergeJsonSectionConfig(configPath, definition.section, serverConfig, definition.section, (parsed) => {
|
|
125
125
|
if (definition.mergeExperimentalModelContextProtocolServers && isPlainObject(parsed.experimental)) {
|
|
126
126
|
mergeExperimentalModelContextProtocolServers(parsed, serverConfig, mcpUrl);
|
|
127
127
|
}
|
|
128
|
-
});
|
|
128
|
+
}, force);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
function requireJsonMcpClientDefinition(clientId) {
|
|
@@ -236,7 +236,96 @@ function mcpRemoteCommandJsonServerConfig(mcpUrl, identity) {
|
|
|
236
236
|
};
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
async function
|
|
239
|
+
export async function removeJsonClientMcpConfig(clientId, configPath, options = {}) {
|
|
240
|
+
const definition = requireJsonMcpClientDefinition(clientId);
|
|
241
|
+
const existing = await readTextIfExists(configPath);
|
|
242
|
+
if (existing.trim().length === 0) {
|
|
243
|
+
return { removed: false, reason: 'missing' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const parsed = parseJsonConfig(existing, configPath);
|
|
247
|
+
if (!isPlainObject(parsed)) {
|
|
248
|
+
throw new UsageError(`MCP JSON config must be an object: ${configPath}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let removed = false;
|
|
252
|
+
const removedUrls = [];
|
|
253
|
+
|
|
254
|
+
const section = parsed[definition.section];
|
|
255
|
+
if (isPlainObject(section)) {
|
|
256
|
+
for (const name of knownMcpServerNames()) {
|
|
257
|
+
if (name in section) {
|
|
258
|
+
const entryUrl = xMemoEntryUrl(section[name]);
|
|
259
|
+
if (entryUrl) {
|
|
260
|
+
removedUrls.push(entryUrl);
|
|
261
|
+
}
|
|
262
|
+
delete section[name];
|
|
263
|
+
removed = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (definition.mergeExperimentalModelContextProtocolServers && isPlainObject(parsed.experimental)) {
|
|
269
|
+
const servers = parsed.experimental.modelContextProtocolServers;
|
|
270
|
+
if (Array.isArray(servers)) {
|
|
271
|
+
const originalLength = servers.length;
|
|
272
|
+
parsed.experimental.modelContextProtocolServers = servers.filter((entry) => !isXMemoExperimentalEntry(entry, removedUrls));
|
|
273
|
+
if (parsed.experimental.modelContextProtocolServers.length !== originalLength) {
|
|
274
|
+
removed = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!removed) {
|
|
280
|
+
return { removed: false, reason: 'not-found' };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!options.preview) {
|
|
284
|
+
await fs.writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
285
|
+
await bestEffortChmod(configPath, 0o600);
|
|
286
|
+
}
|
|
287
|
+
return { removed: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function xMemoEntryUrl(entry) {
|
|
291
|
+
if (!isPlainObject(entry)) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
if (isPlainObject(entry.transport) && typeof entry.transport.url === 'string') {
|
|
295
|
+
return entry.transport.url;
|
|
296
|
+
}
|
|
297
|
+
if (typeof entry.url === 'string') {
|
|
298
|
+
return entry.url;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isXMemoExperimentalEntry(entry, removedUrls = []) {
|
|
304
|
+
if (!isPlainObject(entry)) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
const transport = entry.transport;
|
|
308
|
+
if (!isPlainObject(transport)) {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
if (typeof transport.url === 'string') {
|
|
312
|
+
if (transport.url.includes('xmemo.dev')) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if (removedUrls.includes(transport.url)) {
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const headers = transport.headers;
|
|
320
|
+
if (isPlainObject(headers)) {
|
|
321
|
+
if (headers[AGENT_ID_HEADER] || headers[AGENT_INSTANCE_HEADER]) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function mergeJsonSectionConfig(configPath, sectionName, serverConfig, duplicatePath = sectionName, afterMerge, force = false) {
|
|
240
329
|
const existing = await readTextIfExists(configPath);
|
|
241
330
|
const parsed = existing.trim().length === 0 ? {} : parseJsonConfig(existing, configPath);
|
|
242
331
|
if (!isPlainObject(parsed)) {
|
|
@@ -246,8 +335,8 @@ async function mergeJsonSectionConfig(configPath, sectionName, serverConfig, dup
|
|
|
246
335
|
parsed[sectionName] = {};
|
|
247
336
|
}
|
|
248
337
|
const existingName = existingJsonMcpServerName(parsed[sectionName]);
|
|
249
|
-
if (existingName) {
|
|
250
|
-
throw new UsageError(`MCP config already contains ${duplicatePath}.${existingName}. Edit ${configPath} manually to avoid duplicate server definitions.`);
|
|
338
|
+
if (existingName && !force) {
|
|
339
|
+
throw new UsageError(`MCP config already contains ${duplicatePath}.${existingName}. Edit ${configPath} manually to avoid duplicate server definitions, or use --force to overwrite.`);
|
|
251
340
|
}
|
|
252
341
|
parsed[sectionName][MCP_SERVER_NAME] = serverConfig;
|
|
253
342
|
afterMerge?.(parsed);
|
package/src/mcp/formats/toml.js
CHANGED
|
@@ -47,17 +47,20 @@ ${AGENT_INSTANCE_HEADER} = "${escapeTomlString(agentInstanceId)}"
|
|
|
47
47
|
`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export async function appendGrokServerConfig(configPath, mcpUrl, identity) {
|
|
50
|
+
export async function appendGrokServerConfig(configPath, mcpUrl, identity, force = false) {
|
|
51
51
|
const snippet = grokTomlSnippet(mcpUrl, identity);
|
|
52
|
-
|
|
52
|
+
let existing = await readTextIfExists(configPath);
|
|
53
53
|
const existingName = existingTomlMcpServerName(existing);
|
|
54
54
|
if (existingName) {
|
|
55
|
-
|
|
55
|
+
if (!force) {
|
|
56
|
+
throw new UsageError(`MCP config already contains [mcp_servers.${existingName}]. Edit ${configPath} manually to avoid duplicate server definitions, or use --force to overwrite.`);
|
|
57
|
+
}
|
|
58
|
+
existing = removeTomlServerBlocks(existing, knownMcpServerNames());
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
59
62
|
const prefix = existing.trim().length === 0 ? '' : '\n\n';
|
|
60
|
-
await fs.
|
|
63
|
+
await fs.writeFile(configPath, `${existing.trimEnd()}${prefix}${snippet}\n`, { mode: 0o600 });
|
|
61
64
|
await bestEffortChmod(configPath, 0o600);
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -127,17 +130,40 @@ export async function codexSmokeReport(configPath, env) {
|
|
|
127
130
|
};
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
export async function
|
|
131
|
-
const snippet = codexTomlSnippet(mcpUrl, identity);
|
|
133
|
+
export async function removeTomlServerConfig(configPath, options = {}) {
|
|
132
134
|
const existing = await readTextIfExists(configPath);
|
|
135
|
+
if (existing.trim().length === 0) {
|
|
136
|
+
return { removed: false, reason: 'missing' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const names = knownMcpServerNames();
|
|
140
|
+
const hasAny = names.some((name) => existing.includes(`[mcp_servers.${name}]`));
|
|
141
|
+
if (!hasAny) {
|
|
142
|
+
return { removed: false, reason: 'not-found' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!options.preview) {
|
|
146
|
+
const updated = removeTomlServerBlocks(existing, names);
|
|
147
|
+
await fs.writeFile(configPath, updated ? `${updated.trimEnd()}\n` : '', { mode: 0o600 });
|
|
148
|
+
await bestEffortChmod(configPath, 0o600);
|
|
149
|
+
}
|
|
150
|
+
return { removed: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function appendTomlServerConfig(configPath, mcpUrl, identity, force = false) {
|
|
154
|
+
const snippet = codexTomlSnippet(mcpUrl, identity);
|
|
155
|
+
let existing = await readTextIfExists(configPath);
|
|
133
156
|
const existingName = existingTomlMcpServerName(existing);
|
|
134
157
|
if (existingName) {
|
|
135
|
-
|
|
158
|
+
if (!force) {
|
|
159
|
+
throw new UsageError(`MCP config already contains [mcp_servers.${existingName}]. Edit ${configPath} manually to avoid duplicate server definitions, or use --force to overwrite.`);
|
|
160
|
+
}
|
|
161
|
+
existing = removeTomlServerBlocks(existing, knownMcpServerNames());
|
|
136
162
|
}
|
|
137
163
|
|
|
138
164
|
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
139
165
|
const prefix = existing.trim().length === 0 ? '' : '\n\n';
|
|
140
|
-
await fs.
|
|
166
|
+
await fs.writeFile(configPath, `${existing.trimEnd()}${prefix}${snippet}\n`, { mode: 0o600 });
|
|
141
167
|
await bestEffortChmod(configPath, 0o600);
|
|
142
168
|
}
|
|
143
169
|
|
|
@@ -176,6 +202,28 @@ function tomlServerBlock(content, serverName) {
|
|
|
176
202
|
return block.join('\n');
|
|
177
203
|
}
|
|
178
204
|
|
|
205
|
+
function removeTomlServerBlocks(content, names) {
|
|
206
|
+
const lines = content.split(/\r?\n/);
|
|
207
|
+
const result = [];
|
|
208
|
+
let skipping = false;
|
|
209
|
+
|
|
210
|
+
for (const line of lines) {
|
|
211
|
+
const tableMatch = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
212
|
+
if (tableMatch) {
|
|
213
|
+
const tableName = tableMatch[1].trim();
|
|
214
|
+
skipping = names.some((name) => {
|
|
215
|
+
const prefix = `mcp_servers.${name}`;
|
|
216
|
+
return tableName === prefix || tableName.startsWith(`${prefix}.`);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (!skipping) {
|
|
220
|
+
result.push(line);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
225
|
+
}
|
|
226
|
+
|
|
179
227
|
function tomlStringValue(block, key) {
|
|
180
228
|
const pattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=\\s*"((?:\\\\.|[^"\\\\])*)"\\s*$`, 'm');
|
|
181
229
|
const match = block.match(pattern);
|
package/src/mcp/formats/yaml.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
AGENT_INSTANCE_ENV_VAR,
|
|
6
|
+
LEGACY_MCP_SERVER_NAMES,
|
|
6
7
|
MCP_SERVER_NAME,
|
|
7
8
|
TOKEN_ENV_VAR
|
|
8
9
|
} from '../../core/constants.js';
|
|
@@ -33,11 +34,116 @@ export function hermesYamlSnippet(mcpUrl, identity = envReferenceIdentity('herme
|
|
|
33
34
|
`;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
export async function
|
|
37
|
+
export async function removeHermesMcpConfig(configPath, options = {}) {
|
|
38
|
+
const existing = await readTextIfExists(configPath);
|
|
39
|
+
if (existing.trim().length === 0) {
|
|
40
|
+
return { removed: false, reason: 'missing' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const names = [MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES];
|
|
44
|
+
const lines = existing.split(/\r?\n/);
|
|
45
|
+
const mcpIndex = lines.findIndex((line) => line.trim().startsWith('mcp_servers:'));
|
|
46
|
+
if (mcpIndex === -1) {
|
|
47
|
+
return { removed: false, reason: 'not-found' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (isFlowStyleMcpServersLine(lines[mcpIndex])) {
|
|
51
|
+
return { removed: false, reason: 'manual-edit-required' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const serverBaseIndent = indentOf(lines[mcpIndex + 1]);
|
|
55
|
+
if (serverBaseIndent === null) {
|
|
56
|
+
return { removed: false, reason: 'not-found' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let removed = false;
|
|
60
|
+
let index = mcpIndex + 1;
|
|
61
|
+
while (index < lines.length) {
|
|
62
|
+
const line = lines[index];
|
|
63
|
+
const lineIndent = indentOf(line);
|
|
64
|
+
if (lineIndent === null) {
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (lineIndent < serverBaseIndent) {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
if (lineIndent === serverBaseIndent) {
|
|
72
|
+
const match = names.find((name) => line.trim() === `${name}:` || line.trim().startsWith(`${name}:`));
|
|
73
|
+
if (match) {
|
|
74
|
+
if (hasUnbalancedFlow(line)) {
|
|
75
|
+
return { removed: false, reason: 'manual-edit-required' };
|
|
76
|
+
}
|
|
77
|
+
const start = index;
|
|
78
|
+
let end = index + 1;
|
|
79
|
+
for (; end < lines.length; end += 1) {
|
|
80
|
+
const nextIndent = indentOf(lines[end]);
|
|
81
|
+
if (nextIndent === null) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (nextIndent <= serverBaseIndent) {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
lines.splice(start, end - start);
|
|
89
|
+
removed = true;
|
|
90
|
+
index = start;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
index += 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!removed) {
|
|
98
|
+
return { removed: false, reason: 'not-found' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!options.preview) {
|
|
102
|
+
const updated = lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
103
|
+
await fs.writeFile(configPath, updated ? `${updated}\n` : '', { mode: 0o600 });
|
|
104
|
+
await bestEffortChmod(configPath, 0o600);
|
|
105
|
+
}
|
|
106
|
+
return { removed: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isFlowStyleMcpServersLine(line) {
|
|
110
|
+
const afterKey = line.split('mcp_servers:')[1] ?? '';
|
|
111
|
+
return /[\{\[]/.test(afterKey);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasUnbalancedFlow(line) {
|
|
115
|
+
const flowChars = line.replace(/[^\{\}\[\]\(\)]/g, '');
|
|
116
|
+
const pairs = { ')': '(', '}': '{', ']': '[' };
|
|
117
|
+
let depth = 0;
|
|
118
|
+
for (const char of flowChars) {
|
|
119
|
+
if (Object.values(pairs).includes(char)) {
|
|
120
|
+
depth += 1;
|
|
121
|
+
} else if (pairs[char]) {
|
|
122
|
+
depth -= 1;
|
|
123
|
+
if (depth < 0) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return depth !== 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function indentOf(line) {
|
|
132
|
+
const match = line.match(/^(\s*)\S/);
|
|
133
|
+
if (!match) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return match[1].length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function mergeHermesMcpConfig(configPath, mcpUrl, identity, force = false) {
|
|
37
140
|
const existing = await readTextIfExists(configPath);
|
|
38
141
|
|
|
39
142
|
if (existing.includes(`${MCP_SERVER_NAME}:`) || existing.includes('memory_os:') || existing.includes('memory-os:')) {
|
|
40
|
-
|
|
143
|
+
if (!force) {
|
|
144
|
+
throw new UsageError(`MCP config already contains ${MCP_SERVER_NAME} in mcp_servers. Edit ${configPath} manually to avoid duplicate server definitions, or use --force to overwrite.`);
|
|
145
|
+
}
|
|
146
|
+
throw new UsageError(`--force overwrite is not yet supported for Hermes YAML configs. Edit ${configPath} manually.`);
|
|
41
147
|
}
|
|
42
148
|
|
|
43
149
|
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
package/src/mcp/proxy/copilot.js
CHANGED
|
@@ -9,10 +9,45 @@ import {
|
|
|
9
9
|
parseJsonConfig,
|
|
10
10
|
readTextIfExists
|
|
11
11
|
} from '../../core/runtime.js';
|
|
12
|
-
import { existingJsonMcpServerName } from '../core/names.js';
|
|
12
|
+
import { existingJsonMcpServerName, knownMcpServerNames } from '../core/names.js';
|
|
13
13
|
export { mcpProxyCommand } from './server.js';
|
|
14
14
|
|
|
15
|
-
export async function
|
|
15
|
+
export async function removeCopilotMcpConfig(configPath, options = {}) {
|
|
16
|
+
const existing = await readTextIfExists(configPath);
|
|
17
|
+
if (existing.trim().length === 0) {
|
|
18
|
+
return { removed: false, reason: 'missing' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const parsed = parseJsonConfig(existing, configPath);
|
|
22
|
+
if (!isPlainObject(parsed)) {
|
|
23
|
+
throw new UsageError(`Copilot MCP JSON config must be an object: ${configPath}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!isPlainObject(parsed.mcpServers)) {
|
|
27
|
+
return { removed: false, reason: 'not-found' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let removed = false;
|
|
31
|
+
for (const name of knownMcpServerNames()) {
|
|
32
|
+
if (name in parsed.mcpServers) {
|
|
33
|
+
delete parsed.mcpServers[name];
|
|
34
|
+
removed = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!removed) {
|
|
39
|
+
return { removed: false, reason: 'not-found' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!options.preview) {
|
|
43
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
44
|
+
await fs.writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
await bestEffortChmod(configPath, 0o600);
|
|
46
|
+
}
|
|
47
|
+
return { removed: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function mergeCopilotMcpConfig(configPath, proxyUrl, force = false) {
|
|
16
51
|
const existing = await readTextIfExists(configPath);
|
|
17
52
|
const parsed = existing.trim().length === 0 ? {} : parseJsonConfig(existing, configPath);
|
|
18
53
|
|
|
@@ -25,8 +60,8 @@ export async function mergeCopilotMcpConfig(configPath, proxyUrl) {
|
|
|
25
60
|
}
|
|
26
61
|
|
|
27
62
|
const existingName = existingJsonMcpServerName(parsed.mcpServers);
|
|
28
|
-
if (existingName) {
|
|
29
|
-
throw new UsageError(`MCP config already contains mcpServers.${existingName}. Edit ${configPath} manually to avoid duplicate server definitions.`);
|
|
63
|
+
if (existingName && !force) {
|
|
64
|
+
throw new UsageError(`MCP config already contains mcpServers.${existingName}. Edit ${configPath} manually to avoid duplicate server definitions, or use --force to overwrite.`);
|
|
30
65
|
}
|
|
31
66
|
|
|
32
67
|
parsed.mcpServers[MCP_SERVER_NAME] = copilotLocalProxyServerConfig(proxyUrl);
|
package/src/ui/help.js
CHANGED
|
@@ -14,14 +14,24 @@ export function writeHelp(io) {
|
|
|
14
14
|
writeLine(io.stdout, `Official package: ${PACKAGE_NAME} | Legacy command: ${LEGACY_COMMAND_NAME}`);
|
|
15
15
|
writeLine(io.stdout, '');
|
|
16
16
|
writeLine(io.stdout, '💡 CORE ONBOARDING & SETUP COMMANDS:');
|
|
17
|
-
writeLine(io.stdout, ` ${COMMAND_NAME} setup --all [--write] [--profile]`);
|
|
17
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} setup --all [--write] [--profile] [--force]`);
|
|
18
18
|
writeLine(io.stdout, ` Auto-detects all local client installations (Cursor, VS Code, Continue, Trae, etc.).`);
|
|
19
19
|
writeLine(io.stdout, ` Merges XMemo MCP configs. Pass --profile to auto-inject workspace prompt rules.`);
|
|
20
20
|
writeLine(io.stdout, ` *Dry-run by default unless --write (or --yes/-y) is specified for safety.*`);
|
|
21
|
+
writeLine(io.stdout, ` Pass --force to overwrite an existing mcpServers.XMemo entry.`);
|
|
21
22
|
writeLine(io.stdout, '');
|
|
22
|
-
writeLine(io.stdout, ` ${COMMAND_NAME} setup <client-id> [--url <url>] [--no-profile] [--json]`);
|
|
23
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} setup <client-id> [--url <url>] [--no-profile] [--json] [--force]`);
|
|
23
24
|
writeLine(io.stdout, ` Runs interactive setup wizard for a single client (e.g. cursor, gemini, antigravity).`);
|
|
24
25
|
writeLine(io.stdout, ` Detects active workspace to auto-inject project-scoped instruction rules.`);
|
|
26
|
+
writeLine(io.stdout, ` Pass --force to overwrite an existing mcpServers.XMemo entry.`);
|
|
27
|
+
writeLine(io.stdout, '');
|
|
28
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} uninstall --all [--yes] [--profiles] [--dry-run]`);
|
|
29
|
+
writeLine(io.stdout, ` Removes the XMemo MCP server entry from every detected client config.`);
|
|
30
|
+
writeLine(io.stdout, ` Use --profiles to also remove installed behavior profiles.`);
|
|
31
|
+
writeLine(io.stdout, ` Dry-run by default unless --yes (or -y) is specified.`);
|
|
32
|
+
writeLine(io.stdout, '');
|
|
33
|
+
writeLine(io.stdout, ` ${COMMAND_NAME} uninstall <client-id> [--yes] [--profiles] [--dry-run]`);
|
|
34
|
+
writeLine(io.stdout, ` Removes the XMemo MCP server entry from a single client config.`);
|
|
25
35
|
writeLine(io.stdout, '');
|
|
26
36
|
writeLine(io.stdout, ` ${COMMAND_NAME} login [--from-stdin] [--base-url <url>]`);
|
|
27
37
|
writeLine(io.stdout, ` Starts secure OAuth2 browser-based device login flow to register the CLI.`);
|
package/src/ui/setup.js
CHANGED
|
@@ -223,6 +223,16 @@ export function writeSetupSummary(plan, io) {
|
|
|
223
223
|
if (plan.tokenPortalUrl) {
|
|
224
224
|
writeLine(io.stdout, ` (Token portal: ${plan.tokenPortalUrl})`);
|
|
225
225
|
}
|
|
226
|
+
} else if (cid === 'kimi-code') {
|
|
227
|
+
writeLine(io.stdout, `💡 Next steps for ${plan.selectedClient.label}:`);
|
|
228
|
+
writeLine(io.stdout, ' 1. Restart Kimi Code to load the new MCP configuration.');
|
|
229
|
+
writeLine(io.stdout, ` 2. Make sure ${TOKEN_ENV_VAR} is exported in the SAME environment that starts Kimi Code.`);
|
|
230
|
+
writeLine(io.stdout, ` Kimi Code reads the token from process.env['${TOKEN_ENV_VAR}'] via bearerTokenEnvVar.`);
|
|
231
|
+
writeLine(io.stdout, ` Example (PowerShell): $env:${TOKEN_ENV_VAR}='<your-token>'; kimi`);
|
|
232
|
+
writeLine(io.stdout, ` Example (bash): export ${TOKEN_ENV_VAR}=<your-token> && kimi`);
|
|
233
|
+
if (plan.tokenPortalUrl) {
|
|
234
|
+
writeLine(io.stdout, ` (Token portal: ${plan.tokenPortalUrl})`);
|
|
235
|
+
}
|
|
226
236
|
} else if (usesClientOAuth(cid)) {
|
|
227
237
|
writeLine(io.stdout, `💡 Next steps for ${plan.selectedClient.label}:`);
|
|
228
238
|
writeLine(io.stdout, ' 1. When the agent starts or first makes an XMemo tool call, a browser window will automatically pop up requesting OAuth authorization.');
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMMAND_NAME,
|
|
3
|
+
PRODUCT_NAME
|
|
4
|
+
} from '../core/constants.js';
|
|
5
|
+
import { writeLine } from '../core/io.js';
|
|
6
|
+
|
|
7
|
+
export async function confirmUninstall(plan, io) {
|
|
8
|
+
const targets = plan.removed.length;
|
|
9
|
+
if (targets === 0) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const prompt = plan.dryRun
|
|
14
|
+
? `${PRODUCT_NAME} would remove entries from ${targets} client config(s). Continue? [y/N]`
|
|
15
|
+
: `Remove ${PRODUCT_NAME} entries from ${targets} client config(s)? [y/N]`;
|
|
16
|
+
writeLine(io.stdout, prompt);
|
|
17
|
+
const answer = (await readLine(io.stdin)).trim().toLowerCase();
|
|
18
|
+
return answer === 'y' || answer === 'yes';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readLine(stdin) {
|
|
22
|
+
let input = '';
|
|
23
|
+
for await (const chunk of stdin) {
|
|
24
|
+
input += chunk;
|
|
25
|
+
if (input.includes('\n')) {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return input.split(/\r?\n/, 1)[0] ?? '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeUninstallSummary(plan, io) {
|
|
33
|
+
writeLine(io.stdout, `${PRODUCT_NAME} uninstall summary`);
|
|
34
|
+
|
|
35
|
+
if (plan.dryRun) {
|
|
36
|
+
writeLine(io.stdout, ' (dry run — no files were modified)');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (plan.profiles) {
|
|
40
|
+
writeLine(io.stdout, ' Behavior profiles: included in removal');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (plan.removed.length === 0 && plan.skipped.length === 0 && plan.errors.length === 0) {
|
|
44
|
+
writeLine(io.stdout, ' No local client configurations were detected.');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (plan.removed.length > 0) {
|
|
49
|
+
writeLine(io.stdout, '');
|
|
50
|
+
writeLine(io.stdout, `${plan.dryRun ? 'Would remove' : 'Removed'} from ${plan.removed.length} client(s):`);
|
|
51
|
+
for (const entry of plan.removed) {
|
|
52
|
+
writeLine(io.stdout, ` [✔] ${entry.label}`);
|
|
53
|
+
writeLine(io.stdout, ` Config: ${entry.configPath}`);
|
|
54
|
+
if ('profilePath' in entry && entry.profilePath) {
|
|
55
|
+
const profileStatus = entry.profileStatus === 'removed' ? 'removed' : 'not found';
|
|
56
|
+
writeLine(io.stdout, ` Profile: ${entry.profilePath} (${profileStatus})`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (plan.skipped.length > 0) {
|
|
62
|
+
writeLine(io.stdout, '');
|
|
63
|
+
writeLine(io.stdout, `Skipped ${plan.skipped.length} client(s) (XMemo entry not found):`);
|
|
64
|
+
for (const entry of plan.skipped) {
|
|
65
|
+
writeLine(io.stdout, ` [ ] ${entry.label}`);
|
|
66
|
+
writeLine(io.stdout, ` Config: ${entry.configPath}`);
|
|
67
|
+
if (entry.configStatus) {
|
|
68
|
+
writeLine(io.stdout, ` Reason: ${entry.configStatus}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (plan.errors.length > 0) {
|
|
74
|
+
writeLine(io.stdout, '');
|
|
75
|
+
writeLine(io.stdout, `Encountered ${plan.errors.length} error(s):`);
|
|
76
|
+
for (const error of plan.errors) {
|
|
77
|
+
writeLine(io.stdout, ` [✘] ${error.label} (${error.phase})`);
|
|
78
|
+
writeLine(io.stdout, ` Path: ${error.configPath ?? error.profilePath}`);
|
|
79
|
+
writeLine(io.stdout, ` Error: ${error.error}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!plan.dryRun && plan.removed.length > 0) {
|
|
84
|
+
writeLine(io.stdout, '');
|
|
85
|
+
writeLine(io.stdout, 'Restart your IDEs or reload their MCP configurations to apply the changes.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (plan.removed.length === 0 && plan.skipped.length > 0 && plan.errors.length === 0) {
|
|
89
|
+
writeLine(io.stdout, '');
|
|
90
|
+
writeLine(io.stdout, `Run \`${COMMAND_NAME} setup --all --write\` to re-install if needed.`);
|
|
91
|
+
}
|
|
92
|
+
}
|