@xmemo/client 0.4.168 → 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 +35 -32
- package/src/commands/uninstall.js +238 -0
- package/src/mcp/clients/registry.js +4 -0
- package/src/mcp/clients/scan.js +109 -0
- package/src/mcp/clients.js +9 -3
- package/src/mcp/formats/json.js +90 -1
- package/src/mcp/formats/toml.js +38 -17
- package/src/mcp/formats/yaml.js +103 -0
- package/src/mcp/proxy/copilot.js +36 -1
- package/src/ui/help.js +8 -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
|
|
@@ -93,42 +97,41 @@ export async function setupCommand(args, io) {
|
|
|
93
97
|
|
|
94
98
|
if (setupAll) {
|
|
95
99
|
setupPlan.detectedClients = [];
|
|
96
|
-
const scanIds =
|
|
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
|
-
|
|
125
|
-
}
|
|
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;
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
|
-
setupPlan.detectedClients.push(clientPlan);
|
|
131
133
|
}
|
|
134
|
+
setupPlan.detectedClients.push(clientPlan);
|
|
132
135
|
}
|
|
133
136
|
} else if (clientId) {
|
|
134
137
|
if (clientId === 'copilot-cli') {
|
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,7 @@ export function createMcpClients(deps) {
|
|
|
8
8
|
defaultConfigPath: deps.defaultCodexConfigPath,
|
|
9
9
|
buildSnippet: deps.codexTomlSnippet,
|
|
10
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
|
|
|
@@ -16,6 +17,7 @@ export function createMcpClients(deps) {
|
|
|
16
17
|
defaultConfigPath: deps.defaultGrokConfigPath,
|
|
17
18
|
buildSnippet: deps.grokTomlSnippet,
|
|
18
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
|
|
|
@@ -39,6 +41,7 @@ function jsonClient(definition, deps) {
|
|
|
39
41
|
defaultConfigPath: deps[definition.defaultConfigPath],
|
|
40
42
|
buildSnippet: (mcpUrl, identity) => deps.jsonClientSnippet(definition.id, mcpUrl, identity),
|
|
41
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
|
};
|
|
@@ -50,6 +53,7 @@ function hermesClient(deps) {
|
|
|
50
53
|
defaultConfigPath: deps.defaultHermesConfigPath,
|
|
51
54
|
buildSnippet: deps.hermesYamlSnippet,
|
|
52
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' }),
|
|
@@ -236,6 +236,95 @@ function mcpRemoteCommandJsonServerConfig(mcpUrl, identity) {
|
|
|
236
236
|
};
|
|
237
237
|
}
|
|
238
238
|
|
|
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
|
+
|
|
239
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);
|
package/src/mcp/formats/toml.js
CHANGED
|
@@ -55,7 +55,7 @@ export async function appendGrokServerConfig(configPath, mcpUrl, identity, force
|
|
|
55
55
|
if (!force) {
|
|
56
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
57
|
}
|
|
58
|
-
existing =
|
|
58
|
+
existing = removeTomlServerBlocks(existing, knownMcpServerNames());
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
@@ -130,6 +130,26 @@ export async function codexSmokeReport(configPath, env) {
|
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
export async function removeTomlServerConfig(configPath, options = {}) {
|
|
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
|
+
|
|
133
153
|
export async function appendTomlServerConfig(configPath, mcpUrl, identity, force = false) {
|
|
134
154
|
const snippet = codexTomlSnippet(mcpUrl, identity);
|
|
135
155
|
let existing = await readTextIfExists(configPath);
|
|
@@ -138,7 +158,7 @@ export async function appendTomlServerConfig(configPath, mcpUrl, identity, force
|
|
|
138
158
|
if (!force) {
|
|
139
159
|
throw new UsageError(`MCP config already contains [mcp_servers.${existingName}]. Edit ${configPath} manually to avoid duplicate server definitions, or use --force to overwrite.`);
|
|
140
160
|
}
|
|
141
|
-
existing =
|
|
161
|
+
existing = removeTomlServerBlocks(existing, knownMcpServerNames());
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
@@ -182,25 +202,26 @@ function tomlServerBlock(content, serverName) {
|
|
|
182
202
|
return block.join('\n');
|
|
183
203
|
}
|
|
184
204
|
|
|
185
|
-
function
|
|
186
|
-
const header = `[mcp_servers.${serverName}]`;
|
|
205
|
+
function removeTomlServerBlocks(content, names) {
|
|
187
206
|
const lines = content.split(/\r?\n/);
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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);
|
|
198
221
|
}
|
|
199
222
|
}
|
|
200
223
|
|
|
201
|
-
|
|
202
|
-
const after = lines.slice(end).join('\n');
|
|
203
|
-
return `${before}\n${after}`.trim();
|
|
224
|
+
return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
204
225
|
}
|
|
205
226
|
|
|
206
227
|
function tomlStringValue(block, key) {
|
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,6 +34,108 @@ export function hermesYamlSnippet(mcpUrl, identity = envReferenceIdentity('herme
|
|
|
33
34
|
`;
|
|
34
35
|
}
|
|
35
36
|
|
|
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
|
+
|
|
36
139
|
export async function mergeHermesMcpConfig(configPath, mcpUrl, identity, force = false) {
|
|
37
140
|
const existing = await readTextIfExists(configPath);
|
|
38
141
|
|
package/src/mcp/proxy/copilot.js
CHANGED
|
@@ -9,9 +9,44 @@ 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 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
|
+
|
|
15
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);
|
package/src/ui/help.js
CHANGED
|
@@ -25,6 +25,14 @@ export function writeHelp(io) {
|
|
|
25
25
|
writeLine(io.stdout, ` Detects active workspace to auto-inject project-scoped instruction rules.`);
|
|
26
26
|
writeLine(io.stdout, ` Pass --force to overwrite an existing mcpServers.XMemo entry.`);
|
|
27
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.`);
|
|
35
|
+
writeLine(io.stdout, '');
|
|
28
36
|
writeLine(io.stdout, ` ${COMMAND_NAME} login [--from-stdin] [--base-url <url>]`);
|
|
29
37
|
writeLine(io.stdout, ` Starts secure OAuth2 browser-based device login flow to register the CLI.`);
|
|
30
38
|
writeLine(io.stdout, '');
|
|
@@ -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
|
+
}
|