@xmemo/client 0.4.168 → 0.4.170

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
@@ -12,6 +12,33 @@ setup helper code needed on a user's machine.
12
12
  The XMemo server, database, token registry, deployment files, logs, and
13
13
  internal scripts are not part of this npm package.
14
14
 
15
+ > 🧠 **XMemo is also an MCP Server** — give your AI agents persistent memory across sessions. See [MCP Setup](#mcp-setup) below.
16
+
17
+ ## MCP Server Overview
18
+
19
+ **XMemo** is a user-owned, hosted MCP memory service that lets AI agents persistently store, search, recall, update, and manage notes and memory fragments across sessions, projects, and tools.
20
+
21
+ - **MCP Endpoint**: `https://xmemo.dev/mcp` (Streamable HTTP)
22
+ - **Auth**: Bearer Token (`XMEMO_KEY`) or MCP OAuth
23
+ - **Tools**: `remember`, `recall`, `search_memory`, `update_memory`, `forget`, `redact_memory`, `explain_memory`, `create_memory_todo`, `list_memory_todos`, `complete_memory_todo`, `record_event`, `get_timeline`, `add_expense`
24
+ - **Clients**: Kimi, Claude, Cursor, Copilot, Gemini, Grok, Windsurf, Cline, Trae, Zed, Qwen, and more
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "XMemo": {
30
+ "type": "streamable-http",
31
+ "url": "https://xmemo.dev/mcp",
32
+ "headers": {
33
+ "Authorization": "Bearer ${XMEMO_KEY}"
34
+ }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ See [MCP Setup](#mcp-setup) for detailed client configuration.
41
+
15
42
  ## Install
16
43
 
17
44
  ```bash
@@ -193,6 +220,27 @@ configure MCP only. Use `--dry-run` to preview without writing config or profile
193
220
  files, and `--profile-target <path>` to choose a different behavior profile
194
221
  target.
195
222
 
223
+ ### Uninstall
224
+
225
+ Remove the XMemo MCP server entry from one or all detected client configs:
226
+
227
+ ```bash
228
+ xmemo uninstall --all --dry-run
229
+ xmemo uninstall --all --yes
230
+ xmemo uninstall cursor --yes
231
+ xmemo uninstall --all --yes --profiles
232
+ ```
233
+
234
+ `xmemo uninstall --all` scans the same clients as `setup --all` and removes only
235
+ the `XMemo` entry (and legacy names such as `memory_os`) from each detected
236
+ config file. Other MCP servers are preserved. By default it shows a summary and
237
+ asks for confirmation; pass `--yes` (or `-y`) to skip the prompt, or `--dry-run`
238
+ to preview without modifying files.
239
+
240
+ Pass `--profiles` to also remove installed behavior profiles (Codex `AGENTS.md`,
241
+ Cursor memory profile, etc.). Identity files and credentials are not removed, so
242
+ a later `xmemo setup --all` can re-enable XMemo with the same agent instance ID.
243
+
196
244
  Default behavior profile targets:
197
245
 
198
246
  ```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.170",
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,
@@ -35,9 +35,10 @@ export function mcpConfigTemplate(clientId, mcpUrl, options = {}) {
35
35
 
36
36
  const jsonDefinition = jsonMcpClientDefinition(clientId);
37
37
  if (jsonDefinition) {
38
+ const identityClientId = jsonDefinition.defaultIdentityId ?? clientId;
38
39
  return jsonDefinition.authentication === 'oauth'
39
- ? oauthJsonMcpTemplate(clientId, mcpUrl, jsonClientConfig(clientId, mcpUrl), options)
40
- : bearerJsonMcpTemplate(clientId, mcpUrl, jsonClientConfig(clientId, mcpUrl), options);
40
+ ? oauthJsonMcpTemplate(clientId, identityClientId, mcpUrl, jsonClientConfig(clientId, mcpUrl), options)
41
+ : bearerJsonMcpTemplate(clientId, identityClientId, mcpUrl, jsonClientConfig(clientId, mcpUrl), options);
41
42
  }
42
43
 
43
44
  return {
@@ -112,7 +113,7 @@ export function agentInstanceGenerationPolicy(clientId, options = {}) {
112
113
  };
113
114
  }
114
115
 
115
- function bearerJsonMcpTemplate(clientId, mcpUrl, snippet, options) {
116
+ function bearerJsonMcpTemplate(clientId, identityClientId, mcpUrl, snippet, options) {
116
117
  return {
117
118
  client: clientId,
118
119
  serverName: MCP_SERVER_NAME,
@@ -122,7 +123,7 @@ function bearerJsonMcpTemplate(clientId, mcpUrl, snippet, options) {
122
123
  optionalEnv: [AGENT_INSTANCE_ENV_VAR],
123
124
  authentication: 'env-bearer',
124
125
  agentIdentity: {
125
- agentId: clientId,
126
+ agentId: identityClientId,
126
127
  agentIdHeader: AGENT_ID_HEADER,
127
128
  agentInstanceEnvVar: AGENT_INSTANCE_ENV_VAR,
128
129
  agentInstanceHeader: AGENT_INSTANCE_HEADER
@@ -133,7 +134,7 @@ function bearerJsonMcpTemplate(clientId, mcpUrl, snippet, options) {
133
134
  };
134
135
  }
135
136
 
136
- function oauthJsonMcpTemplate(clientId, mcpUrl, snippet, options) {
137
+ function oauthJsonMcpTemplate(clientId, identityClientId, mcpUrl, snippet, options) {
137
138
  return {
138
139
  client: clientId,
139
140
  serverName: MCP_SERVER_NAME,
@@ -143,7 +144,7 @@ function oauthJsonMcpTemplate(clientId, mcpUrl, snippet, options) {
143
144
  optionalEnv: [AGENT_INSTANCE_ENV_VAR],
144
145
  authentication: 'oauth',
145
146
  agentIdentity: {
146
- agentId: clientId,
147
+ agentId: identityClientId,
147
148
  agentIdHeader: AGENT_ID_HEADER,
148
149
  agentInstanceEnvVar: AGENT_INSTANCE_ENV_VAR,
149
150
  agentInstanceHeader: AGENT_INSTANCE_HEADER
@@ -153,4 +154,3 @@ function oauthJsonMcpTemplate(clientId, mcpUrl, snippet, options) {
153
154
  writesTokenValue: false
154
155
  };
155
156
  }
156
-
@@ -16,15 +16,15 @@ 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' }),
23
23
  httpClientDefinition('gemini-cli', 'Gemini CLI', 'defaultGeminiConfigPath', { urlKey: 'httpUrl', authentication: 'oauth' }),
24
24
  httpClientDefinition('antigravity', 'Antigravity', 'defaultAntigravityConfigPath', { urlKey: 'serverUrl', authentication: 'oauth' }),
25
- httpClientDefinition('antigravity-ide', 'Antigravity IDE', 'defaultAntigravityIdeConfigPath', { urlKey: 'url', authentication: 'oauth', defaultIdentityId: 'antigravity', extra: { type: 'http' } }),
26
- httpClientDefinition('antigravity2', 'Antigravity 2.0', 'defaultAntigravity2ConfigPath', { urlKey: 'url', authentication: 'oauth', defaultIdentityId: 'antigravity', extra: { type: 'http' } }),
27
- httpClientDefinition('antigravity-cli', 'Antigravity CLI', 'defaultAntigravityCliConfigPath', { urlKey: 'httpUrl', authentication: 'oauth', defaultIdentityId: 'antigravity' }),
25
+ httpClientDefinition('antigravity-ide', 'Antigravity IDE', 'defaultAntigravityIdeConfigPath', { urlKey: 'serverUrl', authentication: 'oauth', defaultIdentityId: 'antigravity' }),
26
+ httpClientDefinition('antigravity2', 'Antigravity 2.0', 'defaultAntigravity2ConfigPath', { urlKey: 'serverUrl', authentication: 'oauth', defaultIdentityId: 'antigravity' }),
27
+ httpClientDefinition('antigravity-cli', 'Antigravity CLI', 'defaultAntigravityCliConfigPath', { urlKey: 'serverUrl', authentication: 'oauth', defaultIdentityId: 'antigravity' }),
28
28
  httpClientDefinition('windsurf', 'Windsurf', 'defaultWindsurfConfigPath', { urlKey: 'serverUrl', authentication: 'env-bearer' }),
29
29
  httpClientDefinition('cline', 'Cline', 'defaultClineConfigPath', { urlKey: 'httpUrl', authentication: 'env-bearer' }),
30
30
  nestedTransportClientDefinition('continue', 'Continue', 'defaultContinueConfigPath'),
@@ -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
+ }