@xmemo/client 0.4.166 → 0.4.169

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