xtrm-cli 2.1.4
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/.gemini/settings.json +39 -0
- package/dist/index.cjs +55937 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/index.js +151 -0
- package/lib/atomic-config.js +236 -0
- package/lib/config-adapter.js +231 -0
- package/lib/config-injector.js +80 -0
- package/lib/context.js +73 -0
- package/lib/diff.js +142 -0
- package/lib/env-manager.js +160 -0
- package/lib/sync-mcp-cli.js +345 -0
- package/lib/sync.js +227 -0
- package/lib/transform-gemini.js +119 -0
- package/package.json +43 -0
- package/src/adapters/base.ts +29 -0
- package/src/adapters/claude.ts +38 -0
- package/src/adapters/registry.ts +21 -0
- package/src/commands/help.ts +171 -0
- package/src/commands/install-project.ts +566 -0
- package/src/commands/install-service-skills.ts +251 -0
- package/src/commands/install.ts +534 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +143 -0
- package/src/core/interactive-plan.ts +165 -0
- package/src/core/manifest.ts +26 -0
- package/src/core/preflight.ts +142 -0
- package/src/core/rollback.ts +32 -0
- package/src/core/sync-executor.ts +399 -0
- package/src/index.ts +69 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +222 -0
- package/src/utils/banner.ts +194 -0
- package/src/utils/config-adapter.ts +90 -0
- package/src/utils/config-injector.ts +81 -0
- package/src/utils/env-manager.ts +193 -0
- package/src/utils/hash.ts +42 -0
- package/src/utils/repo-root.ts +39 -0
- package/src/utils/sync-mcp-cli.ts +467 -0
- package/src/utils/theme.ts +37 -0
- package/test/context.test.ts +33 -0
- package/test/hooks.test.ts +277 -0
- package/test/install-project.test.ts +235 -0
- package/test/install-service-skills.test.ts +111 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { execSync, exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import kleur from 'kleur';
|
|
7
|
+
// @ts-ignore
|
|
8
|
+
import ora from 'ora';
|
|
9
|
+
import { ensureEnvFile, loadEnvFile, checkRequiredEnvVars, handleMissingEnvVars, getEnvFilePath } from './env-manager.js';
|
|
10
|
+
|
|
11
|
+
export type AgentName = 'claude' | 'gemini' | 'qwen';
|
|
12
|
+
|
|
13
|
+
interface AgentCLI {
|
|
14
|
+
command: string;
|
|
15
|
+
listArgs: string[];
|
|
16
|
+
addStdio: (name: string, cmd: string, args?: string[], env?: Record<string, string>) => string[];
|
|
17
|
+
addHttp: (name: string, url: string, headers?: Record<string, string>) => string[];
|
|
18
|
+
addSse: (name: string, url: string) => string[];
|
|
19
|
+
remove: (name: string) => string[];
|
|
20
|
+
parseList: (output: string) => string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const AGENT_CLI: Record<AgentName, AgentCLI> = {
|
|
24
|
+
claude: {
|
|
25
|
+
command: 'claude',
|
|
26
|
+
listArgs: ['mcp', 'list'],
|
|
27
|
+
addStdio: (name, cmd, args, env) => {
|
|
28
|
+
const base = ['mcp', 'add', '-s', 'user', name, '--'];
|
|
29
|
+
if (env && Object.keys(env).length > 0) {
|
|
30
|
+
for (const [key, value] of Object.entries(env)) {
|
|
31
|
+
base.push('-e', `${key}=${resolveEnvVar(value)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
base.push(cmd, ...(args || []));
|
|
35
|
+
return base;
|
|
36
|
+
},
|
|
37
|
+
addHttp: (name, url, headers) => {
|
|
38
|
+
const base = ['mcp', 'add', '-s', 'user', '--transport', 'http', name, url];
|
|
39
|
+
if (headers) {
|
|
40
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
41
|
+
base.push('--header', `${key}: ${resolveEnvVar(value)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return base;
|
|
45
|
+
},
|
|
46
|
+
addSse: (name, url) => {
|
|
47
|
+
return ['mcp', 'add', '-s', 'user', '--transport', 'sse', name, url];
|
|
48
|
+
},
|
|
49
|
+
remove: (name) => ['mcp', 'remove', '-s', 'user', name],
|
|
50
|
+
parseList: (output) => parseMcpListOutput(output, /^([a-zA-Z0-9_-]+):/)
|
|
51
|
+
},
|
|
52
|
+
gemini: {
|
|
53
|
+
command: 'gemini',
|
|
54
|
+
listArgs: ['mcp', 'list'], // list doesn't support -s flag, lists all scopes
|
|
55
|
+
addStdio: (name, cmd, args, env) => {
|
|
56
|
+
const base = ['mcp', 'add', '-s', 'user', name, cmd];
|
|
57
|
+
if (args && args.length > 0) base.push(...args);
|
|
58
|
+
if (env && Object.keys(env).length > 0) {
|
|
59
|
+
for (const [key, value] of Object.entries(env)) {
|
|
60
|
+
base.push('-e', `${key}=${resolveEnvVar(value)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return base;
|
|
64
|
+
},
|
|
65
|
+
addHttp: (name, url, headers) => {
|
|
66
|
+
const base = ['mcp', 'add', '-s', 'user', '-t', 'http', name, url];
|
|
67
|
+
if (headers) {
|
|
68
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
69
|
+
base.push('-H', `${key}=${resolveEnvVar(value)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return base;
|
|
73
|
+
},
|
|
74
|
+
addSse: (name, url) => {
|
|
75
|
+
return ['mcp', 'add', '-s', 'user', '-t', 'sse', name, url];
|
|
76
|
+
},
|
|
77
|
+
remove: (name) => ['mcp', 'remove', '-s', 'user', name],
|
|
78
|
+
parseList: (output) => parseMcpListOutput(output, /^✓ ([a-zA-Z0-9_-]+):/)
|
|
79
|
+
},
|
|
80
|
+
qwen: {
|
|
81
|
+
command: 'qwen',
|
|
82
|
+
listArgs: ['mcp', 'list'],
|
|
83
|
+
addStdio: (name, cmd, args, env) => {
|
|
84
|
+
const base = ['mcp', 'add', '-s', 'user', name, cmd];
|
|
85
|
+
if (args && args.length > 0) base.push(...args);
|
|
86
|
+
if (env && Object.keys(env).length > 0) {
|
|
87
|
+
for (const [key, value] of Object.entries(env)) {
|
|
88
|
+
base.push('-e', `${key}=${resolveEnvVar(value)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return base;
|
|
92
|
+
},
|
|
93
|
+
addHttp: (name, url, headers) => {
|
|
94
|
+
const base = ['mcp', 'add', '-s', 'user', '-t', 'http', name, url];
|
|
95
|
+
if (headers) {
|
|
96
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
97
|
+
base.push('-H', `${key}=${resolveEnvVar(value)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return base;
|
|
101
|
+
},
|
|
102
|
+
addSse: (name, url) => {
|
|
103
|
+
return ['mcp', 'add', '-s', 'user', '-t', 'sse', name, url];
|
|
104
|
+
},
|
|
105
|
+
remove: (name) => ['mcp', 'remove', '-s', 'user', name],
|
|
106
|
+
parseList: (output) => parseMcpListOutput(output, /^✓ ([a-zA-Z0-9_-]+):/)
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Strip ANSI escape codes (e.g. qwen wraps ✓ in color codes)
|
|
111
|
+
function stripAnsi(str: string): string {
|
|
112
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseMcpListOutput(output: string, pattern: RegExp): string[] {
|
|
116
|
+
const servers: string[] = [];
|
|
117
|
+
for (const line of output.split('\n')) {
|
|
118
|
+
const match = stripAnsi(line).match(pattern);
|
|
119
|
+
if (match) {
|
|
120
|
+
servers.push(match[1]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return servers;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveEnvVar(value: string): string {
|
|
127
|
+
if (typeof value !== 'string') return value;
|
|
128
|
+
|
|
129
|
+
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_match, envName) => {
|
|
130
|
+
const upperName = envName.toUpperCase();
|
|
131
|
+
const envValue = process.env[upperName];
|
|
132
|
+
if (envValue) {
|
|
133
|
+
return envValue;
|
|
134
|
+
} else {
|
|
135
|
+
console.warn(kleur.yellow(` ⚠️ Environment variable ${upperName} is not set in ${getEnvFilePath()}`));
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function detectAgent(systemRoot: string): AgentName | null {
|
|
142
|
+
const normalizedRoot = systemRoot.replace(/\\/g, '/').toLowerCase();
|
|
143
|
+
if (normalizedRoot.includes('.claude') || normalizedRoot.includes('/claude')) {
|
|
144
|
+
return 'claude';
|
|
145
|
+
} else if (normalizedRoot.includes('.gemini') || normalizedRoot.includes('/gemini')) {
|
|
146
|
+
return 'gemini';
|
|
147
|
+
} else if (normalizedRoot.includes('.qwen') || normalizedRoot.includes('/qwen')) {
|
|
148
|
+
return 'qwen';
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildAddCommand(agent: AgentName, name: string, server: any): string[] | null {
|
|
154
|
+
const cli = AGENT_CLI[agent];
|
|
155
|
+
if (!cli) return null;
|
|
156
|
+
|
|
157
|
+
if (server.url || server.serverUrl) {
|
|
158
|
+
const url = server.url || server.serverUrl;
|
|
159
|
+
const type = server.type || (url.includes('/sse') ? 'sse' : 'http');
|
|
160
|
+
|
|
161
|
+
if (type === 'sse') {
|
|
162
|
+
return cli.addSse(name, url);
|
|
163
|
+
} else {
|
|
164
|
+
return cli.addHttp(name, url, server.headers);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (server.command) {
|
|
169
|
+
return cli.addStdio(name, server.command, server.args, server.env);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.warn(kleur.yellow(` ⚠️ Skipping server "${name}": Unknown configuration`));
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface CommandResult {
|
|
177
|
+
success: boolean;
|
|
178
|
+
dryRun?: boolean;
|
|
179
|
+
skipped?: boolean;
|
|
180
|
+
error?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function executeCommand(agent: AgentName, args: string[], dryRun: boolean = false, displayName?: string): CommandResult {
|
|
184
|
+
const cli = AGENT_CLI[agent];
|
|
185
|
+
|
|
186
|
+
const quotedArgs = args.map(arg => {
|
|
187
|
+
if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
|
|
188
|
+
return `"${arg}"`;
|
|
189
|
+
}
|
|
190
|
+
return arg;
|
|
191
|
+
});
|
|
192
|
+
const command = `${cli.command} ${quotedArgs.join(' ')}`;
|
|
193
|
+
|
|
194
|
+
if (dryRun) {
|
|
195
|
+
console.log(kleur.cyan(` [DRY RUN] ${displayName ?? args.slice(2).join(' ')}`));
|
|
196
|
+
return { success: true, dryRun: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
execSync(command, { stdio: 'pipe', timeout: 10000 });
|
|
201
|
+
console.log(kleur.green(` ✓ ${displayName ?? args.slice(2).join(' ')}`));
|
|
202
|
+
return { success: true };
|
|
203
|
+
} catch (error: any) {
|
|
204
|
+
const stderr = error.stderr?.toString() || error.message;
|
|
205
|
+
|
|
206
|
+
if (stderr.includes('already exists') || stderr.includes('already configured')) {
|
|
207
|
+
let serverName = 'unknown';
|
|
208
|
+
if (agent === 'claude') {
|
|
209
|
+
const addIndex = args.indexOf('add');
|
|
210
|
+
for (let i = addIndex + 1; i < args.length; i++) {
|
|
211
|
+
const arg = args[i];
|
|
212
|
+
if (arg === '--') continue;
|
|
213
|
+
if (arg.startsWith('-')) continue;
|
|
214
|
+
if (['local', 'user', 'project', 'http', 'sse', 'stdio'].includes(arg)) continue;
|
|
215
|
+
serverName = arg;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
} else if (agent === 'gemini' || agent === 'qwen') {
|
|
219
|
+
const addIndex = args.indexOf('add');
|
|
220
|
+
for (let i = addIndex + 1; i < args.length; i++) {
|
|
221
|
+
const arg = args[i];
|
|
222
|
+
if (arg === '-t') { i++; continue; }
|
|
223
|
+
if (arg.startsWith('-')) continue;
|
|
224
|
+
if (['http', 'sse', 'stdio'].includes(arg)) continue;
|
|
225
|
+
serverName = arg;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
serverName = args[2];
|
|
230
|
+
}
|
|
231
|
+
console.log(kleur.dim(` ✓ ${serverName} (already configured)`));
|
|
232
|
+
return { success: true, skipped: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log(kleur.red(` ✗ Failed: ${stderr.trim()}`));
|
|
236
|
+
return { success: false, error: stderr };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function getCurrentServers(agent: AgentName): string[] {
|
|
241
|
+
const cli = AGENT_CLI[agent];
|
|
242
|
+
try {
|
|
243
|
+
const output = execSync(`${cli.command} ${cli.listArgs.join(' ')}`, {
|
|
244
|
+
encoding: 'utf8',
|
|
245
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
246
|
+
});
|
|
247
|
+
return cli.parseList(output);
|
|
248
|
+
} catch (error: any) {
|
|
249
|
+
// Some CLIs (e.g. gemini) write server list to stderr
|
|
250
|
+
const combined = (error.stdout || '') + '\n' + (error.stderr || '');
|
|
251
|
+
return cli.parseList(combined);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function getCurrentServersAsync(agent: AgentName): Promise<string[]> {
|
|
256
|
+
const cli = AGENT_CLI[agent];
|
|
257
|
+
try {
|
|
258
|
+
const { stdout, stderr } = await execAsync(`${cli.command} ${cli.listArgs.join(' ')}`, {
|
|
259
|
+
timeout: 10000,
|
|
260
|
+
});
|
|
261
|
+
// Some CLIs (e.g. gemini) write server list to stderr
|
|
262
|
+
return cli.parseList(stdout + '\n' + stderr);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract ${VAR_NAME} references from a list of server config objects
|
|
270
|
+
*/
|
|
271
|
+
function getServerEnvVarNames(servers: any[]): string[] {
|
|
272
|
+
const vars = new Set<string>();
|
|
273
|
+
const pattern = /\$\{([A-Z0-9_]+)\}/g;
|
|
274
|
+
for (const server of servers) {
|
|
275
|
+
const json = JSON.stringify(server);
|
|
276
|
+
let match;
|
|
277
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
278
|
+
while ((match = re.exec(json)) !== null) {
|
|
279
|
+
vars.add(match[1]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return Array.from(vars);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Sync MCP servers to an agent using official CLI
|
|
287
|
+
*/
|
|
288
|
+
// Prevents ensureEnvFile/loadEnvFile from firing once per target directory
|
|
289
|
+
let envInitialized = false;
|
|
290
|
+
|
|
291
|
+
export async function syncMcpServersWithCli(
|
|
292
|
+
agent: AgentName,
|
|
293
|
+
mcpConfig: any,
|
|
294
|
+
dryRun: boolean = false,
|
|
295
|
+
prune: boolean = false
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
const cli = AGENT_CLI[agent];
|
|
298
|
+
if (!cli) {
|
|
299
|
+
console.log(kleur.yellow(` ⚠️ Unsupported agent: ${agent}`));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!envInitialized) {
|
|
304
|
+
ensureEnvFile();
|
|
305
|
+
loadEnvFile();
|
|
306
|
+
envInitialized = true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const spinner = ora({ text: kleur.dim(' checking installed servers…'), indent: 2 }).start();
|
|
310
|
+
const currentServers = await getCurrentServersAsync(agent);
|
|
311
|
+
spinner.stop();
|
|
312
|
+
const currentServersSet = new Set(currentServers);
|
|
313
|
+
const canonicalServers = new Set(Object.keys(mcpConfig.mcpServers || {}));
|
|
314
|
+
|
|
315
|
+
if (prune) {
|
|
316
|
+
const toRemove = currentServers.filter(s => !canonicalServers.has(s));
|
|
317
|
+
if (toRemove.length > 0) {
|
|
318
|
+
console.log(kleur.red(` Pruning ${toRemove.length} server(s)...`));
|
|
319
|
+
for (const serverName of toRemove) {
|
|
320
|
+
executeCommand(agent, cli.remove(serverName), dryRun);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Determine which servers actually need to be added
|
|
326
|
+
const toAdd = Object.entries(mcpConfig.mcpServers || {}).filter(([name]) => !currentServersSet.has(name));
|
|
327
|
+
const skippedCount = canonicalServers.size - toAdd.length;
|
|
328
|
+
|
|
329
|
+
if (toAdd.length === 0) {
|
|
330
|
+
// Nothing to add — skip env warning, no CLI calls needed
|
|
331
|
+
console.log(kleur.dim(` ✓ ${skippedCount} server(s) already installed`));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Step 1: Multiselect — all servers pre-selected, user can deselect with space
|
|
336
|
+
let selectedNames: string[] = toAdd.map(([name]) => name);
|
|
337
|
+
|
|
338
|
+
if (!dryRun) {
|
|
339
|
+
// @ts-ignore
|
|
340
|
+
const prompts = await import('prompts');
|
|
341
|
+
const { selected } = await prompts.default({
|
|
342
|
+
type: 'multiselect',
|
|
343
|
+
name: 'selected',
|
|
344
|
+
message: `Select MCP servers to install via ${agent} CLI:`,
|
|
345
|
+
choices: toAdd.map(([name, server]: [string, any]) => ({
|
|
346
|
+
title: name,
|
|
347
|
+
description: (server as any)._notes?.description || '',
|
|
348
|
+
value: name,
|
|
349
|
+
selected: true
|
|
350
|
+
})),
|
|
351
|
+
hint: '- Space to toggle. Enter to confirm.',
|
|
352
|
+
instructions: false
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!selected || selected.length === 0) {
|
|
356
|
+
console.log(kleur.gray(' Skipped MCP installation.'));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
selectedNames = selected;
|
|
361
|
+
|
|
362
|
+
// Step 2: Only ask for env vars needed by the selected servers
|
|
363
|
+
const selectedEntries = toAdd.filter(([name]) => new Set(selectedNames).has(name));
|
|
364
|
+
const neededVarNames = getServerEnvVarNames(selectedEntries.map(([, s]) => s as any));
|
|
365
|
+
const missingEnvVars = checkRequiredEnvVars(neededVarNames);
|
|
366
|
+
if (missingEnvVars.length > 0) {
|
|
367
|
+
const shouldProceed = await handleMissingEnvVars(missingEnvVars);
|
|
368
|
+
if (!shouldProceed) return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Step 3: Build and execute commands for selected servers only
|
|
373
|
+
const selectedSet = new Set(selectedNames);
|
|
374
|
+
const commandsToRun: Array<{ name: string; cmd: string[] }> = [];
|
|
375
|
+
for (const [name, server] of toAdd) {
|
|
376
|
+
if (!selectedSet.has(name)) continue;
|
|
377
|
+
const cmd = buildAddCommand(agent, name, server as any);
|
|
378
|
+
if (cmd) commandsToRun.push({ name, cmd });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (commandsToRun.length === 0) return;
|
|
382
|
+
|
|
383
|
+
let successCount = 0;
|
|
384
|
+
for (const { name, cmd } of commandsToRun) {
|
|
385
|
+
const result = executeCommand(agent, cmd, dryRun, name);
|
|
386
|
+
if (result.success && !result.skipped) {
|
|
387
|
+
successCount++;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (skippedCount > 0) {
|
|
392
|
+
console.log(kleur.dim(` ✓ ${skippedCount} already installed, ${successCount} added`));
|
|
393
|
+
} else {
|
|
394
|
+
console.log(kleur.green(` ✓ ${successCount} server(s) added`));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Load canonical MCP config from repository
|
|
400
|
+
*/
|
|
401
|
+
export function loadCanonicalMcpConfig(repoRoot: string, includeOptional: boolean = false): any {
|
|
402
|
+
const corePath = path.join(repoRoot, 'config', 'mcp_servers.json');
|
|
403
|
+
const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
|
|
404
|
+
|
|
405
|
+
const config: any = { mcpServers: {} };
|
|
406
|
+
|
|
407
|
+
if (fs.existsSync(corePath)) {
|
|
408
|
+
const core = fs.readJsonSync(corePath);
|
|
409
|
+
config.mcpServers = { ...config.mcpServers, ...core.mcpServers };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (includeOptional && fs.existsSync(optionalPath)) {
|
|
413
|
+
const optional = fs.readJsonSync(optionalPath);
|
|
414
|
+
config.mcpServers = { ...config.mcpServers, ...optional.mcpServers };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return config;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Prompt user to select optional MCP servers
|
|
422
|
+
*/
|
|
423
|
+
export async function promptOptionalServers(repoRoot: string): Promise<string[] | false> {
|
|
424
|
+
const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
|
|
425
|
+
|
|
426
|
+
if (!fs.existsSync(optionalPath)) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const optional = fs.readJsonSync(optionalPath);
|
|
431
|
+
const servers = Object.entries(optional.mcpServers || {}).map(([name, server]: [string, any]) => ({
|
|
432
|
+
name,
|
|
433
|
+
description: server._notes?.description || 'No description',
|
|
434
|
+
prerequisite: server._notes?.prerequisite || ''
|
|
435
|
+
}));
|
|
436
|
+
|
|
437
|
+
if (servers.length === 0) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// @ts-ignore
|
|
442
|
+
const prompts = await import('prompts');
|
|
443
|
+
|
|
444
|
+
const { selected } = await prompts.default({
|
|
445
|
+
type: 'multiselect',
|
|
446
|
+
name: 'selected',
|
|
447
|
+
message: 'Optional MCP servers available — select to install (space to toggle, enter to confirm):',
|
|
448
|
+
choices: servers.map(s => ({
|
|
449
|
+
title: s.name,
|
|
450
|
+
description: s.prerequisite
|
|
451
|
+
? `${s.description} — ⚠️ ${s.prerequisite}`
|
|
452
|
+
: s.description,
|
|
453
|
+
value: s.name,
|
|
454
|
+
selected: false
|
|
455
|
+
})),
|
|
456
|
+
hint: '- Space to select. Enter to skip or confirm.',
|
|
457
|
+
instructions: false
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
if (!selected || selected.length === 0) {
|
|
461
|
+
console.log(kleur.gray(' Skipping optional servers.\n'));
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
console.log(kleur.green(` Selected: ${selected.join(', ')}\n`));
|
|
466
|
+
return selected;
|
|
467
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
|
|
3
|
+
/** Semantic color tokens */
|
|
4
|
+
export const t = {
|
|
5
|
+
// Status
|
|
6
|
+
success: (s: string) => kleur.green(s),
|
|
7
|
+
error: (s: string) => kleur.red(s),
|
|
8
|
+
warning: (s: string) => kleur.yellow(s),
|
|
9
|
+
info: (s: string) => kleur.cyan(s), // was blue — unreadable on dark terminals
|
|
10
|
+
|
|
11
|
+
// Hierarchy (3-tier weight system)
|
|
12
|
+
header: (s: string) => kleur.bold().white(s), // section headers
|
|
13
|
+
label: (s: string) => kleur.dim(s), // metadata labels
|
|
14
|
+
muted: (s: string) => kleur.gray(s),
|
|
15
|
+
accent: (s: string) => kleur.cyan(s),
|
|
16
|
+
bold: (s: string) => kleur.bold(s),
|
|
17
|
+
dim: (s: string) => kleur.dim(s),
|
|
18
|
+
|
|
19
|
+
// Compound
|
|
20
|
+
boldGreen: (s: string) => kleur.bold().green(s),
|
|
21
|
+
boldRed: (s: string) => kleur.bold().red(s),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Status symbols with colour baked in */
|
|
25
|
+
export const sym = {
|
|
26
|
+
ok: kleur.green('✓'),
|
|
27
|
+
fail: kleur.red('✗'),
|
|
28
|
+
warn: kleur.yellow('⚠'),
|
|
29
|
+
|
|
30
|
+
// File change states — directional metaphor
|
|
31
|
+
missing: kleur.green('+'),
|
|
32
|
+
outdated: kleur.yellow('↑'),
|
|
33
|
+
drifted: kleur.magenta('≠'), // was red '!' — magenta = conflict/divergence
|
|
34
|
+
|
|
35
|
+
arrow: kleur.gray('→'),
|
|
36
|
+
bullet: kleur.gray('•'),
|
|
37
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getCandidatePaths, resolveTargets } from '../src/core/context.js';
|
|
3
|
+
|
|
4
|
+
describe('getCandidatePaths', () => {
|
|
5
|
+
it('includes Claude Code and skills-only targets', () => {
|
|
6
|
+
const candidates = getCandidatePaths();
|
|
7
|
+
expect(candidates.some(candidate => candidate.label === '~/.claude (hooks + skills)')).toBe(true);
|
|
8
|
+
expect(candidates.some(candidate => candidate.label === '~/.agents/skills')).toBe(true);
|
|
9
|
+
expect(candidates.length).toBe(2);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('resolveTargets', () => {
|
|
14
|
+
it('returns all candidate paths for the "*" selector', () => {
|
|
15
|
+
const candidates = getCandidatePaths();
|
|
16
|
+
expect(resolveTargets('*', candidates)).toEqual(candidates.map(candidate => candidate.path));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns all candidate paths for the "all" selector', () => {
|
|
20
|
+
const candidates = getCandidatePaths();
|
|
21
|
+
expect(resolveTargets('all', candidates)).toEqual(candidates.map(candidate => candidate.path));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns null when no selector is provided', () => {
|
|
25
|
+
expect(resolveTargets(undefined, getCandidatePaths())).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects unknown selectors', () => {
|
|
29
|
+
expect(() => resolveTargets('everything', getCandidatePaths())).toThrow(
|
|
30
|
+
"Unknown install target selector 'everything'. Use '*' or 'all'.",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|