@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 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.168",
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
  }
@@ -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
- import { detectClient } from '../mcp/clients/detect.js';
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 = ['codex', 'cursor', 'copilot-cli', 'gemini-cli', 'antigravity', 'antigravity-ide', 'antigravity2', 'antigravity-cli', 'windsurf', 'cline', 'continue', 'claude-desktop', 'qwen', 'opencode', 'trae', 'trae-solo'];
97
- for (const scanId of scanIds) {
98
- const detection = await detectClient(scanId, io.env, MCP_CLIENTS);
99
- if (detection.detected) {
100
- let clientPlan;
101
- if (scanId === 'copilot-cli') {
102
- const proxyPort = parsePositiveInteger(optionValue(optionArgs, '--port') ?? String(DEFAULT_PROXY_PORT), '--port');
103
- clientPlan = copilotSetupPlan(setupPlan.mcpUrl, proxyPort, io.env);
104
- clientPlan.configPath = detection.path;
105
- if (writeConfig) {
106
- await mergeCopilotMcpConfig(clientPlan.configPath, clientPlan.proxyUrl, force);
107
- clientPlan.written = true;
108
- }
109
- } else {
110
- const client = MCP_CLIENTS.get(scanId);
111
- const identity = writeConfig ? await agentIdentity(scanId, io.env) : envReferenceIdentity(scanId);
112
- clientPlan = clientSetupPlan(scanId, client, setupPlan.mcpUrl, io.env, identity);
113
- clientPlan.configPath = detection.path;
114
- if (writeConfig) {
115
- await client.writeConfig(clientPlan.configPath, setupPlan.mcpUrl, identity, { force });
116
- clientPlan.written = true;
117
- if (profileClientConfig(scanId)) {
118
- const installProfile = hasFlag(optionArgs, '--yes') || hasFlag(optionArgs, '--profile');
119
- if (installProfile) {
120
- const profileTarget = defaultProfileTarget(scanId, io.env);
121
- const profileResult = await profileInstallResult(scanId, profileTarget, { write: true });
122
- clientPlan.behaviorProfile = profileResult;
123
- if (scanId === 'codex') {
124
- clientPlan.codexProfile = profileResult;
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
+ }
@@ -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,
@@ -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);
@@ -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 = removeTomlServerBlock(existing, existingName);
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 = removeTomlServerBlock(existing, existingName);
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 removeTomlServerBlock(content, serverName) {
186
- const header = `[mcp_servers.${serverName}]`;
205
+ function removeTomlServerBlocks(content, names) {
187
206
  const lines = content.split(/\r?\n/);
188
- const start = lines.findIndex((line) => line.trim() === header);
189
- if (start === -1) {
190
- return content;
191
- }
192
-
193
- let end = start + 1;
194
- for (; end < lines.length; end += 1) {
195
- const line = lines[end];
196
- if (/^\s*\[/.test(line)) {
197
- break;
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
- const before = lines.slice(0, start).join('\n');
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) {
@@ -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
 
@@ -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
+ }