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,345 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import kleur from 'kleur';
|
|
5
|
+
import { ensureEnvFile, loadEnvFile, checkRequiredEnvVars, handleMissingEnvVars, getEnvFilePath } from './env-manager.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Agent-specific MCP CLI handlers
|
|
9
|
+
*/
|
|
10
|
+
const AGENT_CLI = {
|
|
11
|
+
claude: {
|
|
12
|
+
command: 'claude',
|
|
13
|
+
listArgs: ['mcp', 'list'], // list doesn't support -s flag
|
|
14
|
+
addStdio: (name, cmd, args, env) => {
|
|
15
|
+
// Use -s user for user-level config (~/.claude.json global)
|
|
16
|
+
const base = ['mcp', 'add', '-s', 'user', name, '--'];
|
|
17
|
+
if (env && Object.keys(env).length > 0) {
|
|
18
|
+
for (const [key, value] of Object.entries(env)) {
|
|
19
|
+
base.push('-e', `${key}=${resolveEnvVar(value)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
base.push(cmd, ...(args || []));
|
|
23
|
+
return base;
|
|
24
|
+
},
|
|
25
|
+
addHttp: (name, url, headers) => {
|
|
26
|
+
// Use -s user for user-level config
|
|
27
|
+
const base = ['mcp', 'add', '-s', 'user', '--transport', 'http', name, url];
|
|
28
|
+
if (headers) {
|
|
29
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
30
|
+
base.push('--header', `${key}: ${resolveEnvVar(value)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return base;
|
|
34
|
+
},
|
|
35
|
+
addSse: (name, url) => {
|
|
36
|
+
return ['mcp', 'add', '-s', 'user', '--transport', 'sse', name, url];
|
|
37
|
+
},
|
|
38
|
+
remove: (name) => ['mcp', 'remove', '-s', 'user', name],
|
|
39
|
+
parseList: (output) => parseMcpListOutput(output, /^([a-zA-Z0-9_-]+):/)
|
|
40
|
+
},
|
|
41
|
+
gemini: {
|
|
42
|
+
command: 'gemini',
|
|
43
|
+
listArgs: ['mcp', 'list'],
|
|
44
|
+
addStdio: (name, cmd, args, env) => {
|
|
45
|
+
const base = ['mcp', 'add', name, cmd];
|
|
46
|
+
if (args && args.length > 0) base.push(...args);
|
|
47
|
+
if (env && Object.keys(env).length > 0) {
|
|
48
|
+
for (const [key, value] of Object.entries(env)) {
|
|
49
|
+
base.push('-e', `${key}=${resolveEnvVar(value)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return base;
|
|
53
|
+
},
|
|
54
|
+
addHttp: (name, url, headers) => {
|
|
55
|
+
const base = ['mcp', 'add', '-t', 'http', name, url];
|
|
56
|
+
if (headers) {
|
|
57
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
58
|
+
base.push('-H', `${key}=${resolveEnvVar(value)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return base;
|
|
62
|
+
},
|
|
63
|
+
addSse: (name, url) => {
|
|
64
|
+
return ['mcp', 'add', '-t', 'sse', name, url];
|
|
65
|
+
},
|
|
66
|
+
remove: (name) => ['mcp', 'remove', name],
|
|
67
|
+
parseList: (output) => parseMcpListOutput(output, /^✓ ([a-zA-Z0-9_-]+):/)
|
|
68
|
+
},
|
|
69
|
+
qwen: {
|
|
70
|
+
command: 'qwen',
|
|
71
|
+
listArgs: ['mcp', 'list'],
|
|
72
|
+
addStdio: (name, cmd, args, env) => {
|
|
73
|
+
const base = ['mcp', 'add', name, cmd];
|
|
74
|
+
if (args && args.length > 0) base.push(...args);
|
|
75
|
+
if (env && Object.keys(env).length > 0) {
|
|
76
|
+
for (const [key, value] of Object.entries(env)) {
|
|
77
|
+
base.push('-e', `${key}=${resolveEnvVar(value)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return base;
|
|
81
|
+
},
|
|
82
|
+
addHttp: (name, url, headers) => {
|
|
83
|
+
const base = ['mcp', 'add', '-t', 'http', name, url];
|
|
84
|
+
if (headers) {
|
|
85
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
86
|
+
base.push('-H', `${key}=${resolveEnvVar(value)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return base;
|
|
90
|
+
},
|
|
91
|
+
addSse: (name, url) => {
|
|
92
|
+
return ['mcp', 'add', '-t', 'sse', name, url];
|
|
93
|
+
},
|
|
94
|
+
remove: (name) => ['mcp', 'remove', name],
|
|
95
|
+
parseList: (output) => parseMcpListOutput(output, /^✓ ([a-zA-Z0-9_-]+):/)
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse MCP list output to extract server names
|
|
101
|
+
*/
|
|
102
|
+
function parseMcpListOutput(output, pattern) {
|
|
103
|
+
const servers = [];
|
|
104
|
+
for (const line of output.split('\n')) {
|
|
105
|
+
const match = line.match(pattern);
|
|
106
|
+
if (match) {
|
|
107
|
+
servers.push(match[1]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return servers;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve environment variable references like ${VAR}
|
|
115
|
+
*/
|
|
116
|
+
function resolveEnvVar(value) {
|
|
117
|
+
if (typeof value !== 'string') return value;
|
|
118
|
+
|
|
119
|
+
const envMatch = value.match(/\$\{([A-Z0-9_]+)\}/i);
|
|
120
|
+
if (envMatch) {
|
|
121
|
+
const envName = envMatch[1];
|
|
122
|
+
const envValue = process.env[envName];
|
|
123
|
+
if (envValue) {
|
|
124
|
+
return envValue;
|
|
125
|
+
} else {
|
|
126
|
+
// Return empty string - server will be added but won't work until key is added
|
|
127
|
+
console.warn(kleur.yellow(` ⚠️ Environment variable ${envName} is not set in ${getEnvFilePath()}`));
|
|
128
|
+
return '';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Detect which agent CLI is available
|
|
137
|
+
*/
|
|
138
|
+
export function detectAgent(systemRoot) {
|
|
139
|
+
if (systemRoot.includes('.claude') || systemRoot.includes('Claude')) {
|
|
140
|
+
return 'claude';
|
|
141
|
+
} else if (systemRoot.includes('.gemini') || systemRoot.includes('Gemini')) {
|
|
142
|
+
return 'gemini';
|
|
143
|
+
} else if (systemRoot.includes('.qwen') || systemRoot.includes('Qwen')) {
|
|
144
|
+
return 'qwen';
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build MCP add commands for a server
|
|
151
|
+
*/
|
|
152
|
+
function buildAddCommand(agent, name, server) {
|
|
153
|
+
const cli = AGENT_CLI[agent];
|
|
154
|
+
if (!cli) return null;
|
|
155
|
+
|
|
156
|
+
// HTTP/SSE servers
|
|
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
|
+
// Stdio servers
|
|
169
|
+
if (server.command) {
|
|
170
|
+
return cli.addStdio(name, server.command, server.args, server.env);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.warn(kleur.yellow(` ⚠️ Skipping server "${name}": Unknown configuration`));
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Execute an MCP command
|
|
179
|
+
*/
|
|
180
|
+
function executeCommand(agent, args, dryRun = false) {
|
|
181
|
+
const cli = AGENT_CLI[agent];
|
|
182
|
+
|
|
183
|
+
// Build command string with proper quoting for arguments with spaces
|
|
184
|
+
const quotedArgs = args.map(arg => {
|
|
185
|
+
if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
|
|
186
|
+
return `"${arg}"`;
|
|
187
|
+
}
|
|
188
|
+
return arg;
|
|
189
|
+
});
|
|
190
|
+
const command = `${cli.command} ${quotedArgs.join(' ')}`;
|
|
191
|
+
|
|
192
|
+
if (dryRun) {
|
|
193
|
+
console.log(kleur.cyan(` [DRY RUN] ${command}`));
|
|
194
|
+
return { success: true, dryRun: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
execSync(command, { stdio: 'pipe' });
|
|
199
|
+
console.log(kleur.green(` ✓ ${args.slice(2).join(' ')}`));
|
|
200
|
+
return { success: true };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const stderr = error.stderr?.toString() || error.message;
|
|
203
|
+
|
|
204
|
+
// Handle "already exists" case as success (idempotent)
|
|
205
|
+
if (stderr.includes('already exists') || stderr.includes('already configured')) {
|
|
206
|
+
// Extract server name based on agent
|
|
207
|
+
let serverName = 'unknown';
|
|
208
|
+
if (agent === 'claude') {
|
|
209
|
+
// Claude: claude mcp add [-s scope] [--transport type] <name> ...
|
|
210
|
+
// Find the server name: first non-flag arg after 'add' that's not scope or transport
|
|
211
|
+
const addIndex = args.indexOf('add');
|
|
212
|
+
for (let i = addIndex + 1; i < args.length; i++) {
|
|
213
|
+
const arg = args[i];
|
|
214
|
+
if (arg === '--') continue; // Skip separator
|
|
215
|
+
if (arg.startsWith('-')) continue; // Skip flags
|
|
216
|
+
if (['local', 'user', 'project', 'http', 'sse', 'stdio'].includes(arg)) continue; // Skip scope and transport
|
|
217
|
+
serverName = arg;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
} else if (agent === 'gemini' || agent === 'qwen') {
|
|
221
|
+
// Gemini/Qwen: <agent> mcp add [-t type] <name> <url>...
|
|
222
|
+
// Find first non-flag arg after 'add' that's not a transport type
|
|
223
|
+
const addIndex = args.indexOf('add');
|
|
224
|
+
for (let i = addIndex + 1; i < args.length; i++) {
|
|
225
|
+
const arg = args[i];
|
|
226
|
+
if (arg === '-t') { i++; continue; } // Skip transport flag and value
|
|
227
|
+
if (arg.startsWith('-')) continue; // Skip other flags
|
|
228
|
+
if (['http', 'sse', 'stdio'].includes(arg)) continue; // Skip transport types
|
|
229
|
+
serverName = arg;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
// Fallback: use third arg
|
|
234
|
+
serverName = args[2];
|
|
235
|
+
}
|
|
236
|
+
console.log(kleur.dim(` ✓ ${serverName} (already configured)`));
|
|
237
|
+
return { success: true, skipped: true };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(kleur.red(` ✗ Failed: ${stderr.trim()}`));
|
|
241
|
+
return { success: false, error: stderr };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get current MCP servers for an agent
|
|
247
|
+
*/
|
|
248
|
+
function getCurrentServers(agent) {
|
|
249
|
+
const cli = AGENT_CLI[agent];
|
|
250
|
+
try {
|
|
251
|
+
const output = execSync(`${cli.command} ${cli.listArgs.join(' ')}`, {
|
|
252
|
+
encoding: 'utf8',
|
|
253
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
254
|
+
});
|
|
255
|
+
return cli.parseList(output);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Sync MCP servers to an agent using official CLI
|
|
263
|
+
*
|
|
264
|
+
* @param {string} agent - 'claude', 'gemini', or 'qwen'
|
|
265
|
+
* @param {Object} mcpConfig - Canonical MCP configuration
|
|
266
|
+
* @param {boolean} dryRun - Show commands without executing
|
|
267
|
+
* @param {boolean} prune - Remove servers not in canonical config
|
|
268
|
+
*/
|
|
269
|
+
export async function syncMcpServersWithCli(agent, mcpConfig, dryRun = false, prune = false) {
|
|
270
|
+
const cli = AGENT_CLI[agent];
|
|
271
|
+
if (!cli) {
|
|
272
|
+
console.log(kleur.yellow(` ⚠️ Unsupported agent: ${agent}`));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
console.log(kleur.bold(`\nSyncing MCP servers to ${agent}...`));
|
|
277
|
+
|
|
278
|
+
// Step 0: Ensure .env file exists and load it
|
|
279
|
+
ensureEnvFile();
|
|
280
|
+
loadEnvFile();
|
|
281
|
+
|
|
282
|
+
// Step 1: Check for missing required env vars
|
|
283
|
+
const missingEnvVars = checkRequiredEnvVars();
|
|
284
|
+
if (missingEnvVars.length > 0) {
|
|
285
|
+
handleMissingEnvVars(missingEnvVars);
|
|
286
|
+
// Continue anyway - servers will be added but may not work until keys are added
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Step 2: Get current servers
|
|
290
|
+
const currentServers = getCurrentServers(agent);
|
|
291
|
+
const canonicalServers = new Set(Object.keys(mcpConfig.mcpServers || {}));
|
|
292
|
+
|
|
293
|
+
// Step 2: Remove servers not in canonical (if prune mode)
|
|
294
|
+
if (prune) {
|
|
295
|
+
console.log(kleur.red('\n Prune mode: Removing servers not in canonical config...'));
|
|
296
|
+
for (const serverName of currentServers) {
|
|
297
|
+
if (!canonicalServers.has(serverName)) {
|
|
298
|
+
console.log(kleur.red(` Removing: ${serverName}`));
|
|
299
|
+
executeCommand(agent, cli.remove(serverName), dryRun);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Step 3: Add/update canonical servers
|
|
305
|
+
console.log(kleur.cyan('\n Adding/Updating canonical servers...'));
|
|
306
|
+
let successCount = 0;
|
|
307
|
+
|
|
308
|
+
for (const [name, server] of Object.entries(mcpConfig.mcpServers)) {
|
|
309
|
+
const cmd = buildAddCommand(agent, name, server);
|
|
310
|
+
if (cmd) {
|
|
311
|
+
const result = executeCommand(agent, cmd, dryRun);
|
|
312
|
+
if (result.success) {
|
|
313
|
+
successCount++;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Summary
|
|
319
|
+
console.log(kleur.green(`\n ✓ Synced ${successCount} MCP servers`));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Load canonical MCP config from repository
|
|
324
|
+
*/
|
|
325
|
+
export function loadCanonicalMcpConfig(repoRoot) {
|
|
326
|
+
const corePath = path.join(repoRoot, 'config', 'mcp_servers.json');
|
|
327
|
+
const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
|
|
328
|
+
|
|
329
|
+
const config = { mcpServers: {} };
|
|
330
|
+
|
|
331
|
+
// Always load core servers
|
|
332
|
+
if (fs.existsSync(corePath)) {
|
|
333
|
+
const core = fs.readJsonSync(corePath);
|
|
334
|
+
config.mcpServers = { ...config.mcpServers, ...core.mcpServers };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Optionally load optional servers (user would have selected these)
|
|
338
|
+
// For now, we don't auto-load them
|
|
339
|
+
// if (fs.existsSync(optionalPath)) {
|
|
340
|
+
// const optional = fs.readJsonSync(optionalPath);
|
|
341
|
+
// config.mcpServers = { ...config.mcpServers, ...optional.mcpServers };
|
|
342
|
+
// }
|
|
343
|
+
|
|
344
|
+
return config;
|
|
345
|
+
}
|
package/lib/sync.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import kleur from 'kleur';
|
|
4
|
+
import { transformGeminiConfig, transformSkillToCommand } from './transform-gemini.js';
|
|
5
|
+
import { safeMergeConfig } from './atomic-config.js';
|
|
6
|
+
import { ConfigAdapter } from './config-adapter.js';
|
|
7
|
+
import { syncMcpServersWithCli, loadCanonicalMcpConfig, detectAgent } from './sync-mcp-cli.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a sync plan based on changeset and mode
|
|
11
|
+
*/
|
|
12
|
+
export async function executeSync(repoRoot, systemRoot, changeSet, mode, actionType, isDryRun = false) {
|
|
13
|
+
const isClaude = systemRoot.includes('.claude') || systemRoot.includes('Claude');
|
|
14
|
+
const isQwen = systemRoot.includes('.qwen') || systemRoot.includes('Qwen');
|
|
15
|
+
const isGemini = systemRoot.includes('.gemini') || systemRoot.includes('Gemini');
|
|
16
|
+
const categories = ['skills', 'hooks', 'config'];
|
|
17
|
+
|
|
18
|
+
if (isQwen) {
|
|
19
|
+
categories.push('qwen-commands');
|
|
20
|
+
} else if (isGemini) {
|
|
21
|
+
categories.push('commands', 'antigravity-workflows');
|
|
22
|
+
} else if (!isClaude) {
|
|
23
|
+
categories.push('commands');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let count = 0;
|
|
27
|
+
const adapter = new ConfigAdapter(systemRoot);
|
|
28
|
+
|
|
29
|
+
// Special handling for agents with official MCP CLI: Always sync MCP servers
|
|
30
|
+
// Supported: claude, gemini, qwen
|
|
31
|
+
const agent = detectAgent(systemRoot);
|
|
32
|
+
if (agent && actionType === 'sync') {
|
|
33
|
+
console.log(kleur.gray(` --> ${agent} MCP servers (via ${agent} mcp CLI)`));
|
|
34
|
+
|
|
35
|
+
// Load canonical MCP config
|
|
36
|
+
const canonicalConfig = loadCanonicalMcpConfig(repoRoot);
|
|
37
|
+
|
|
38
|
+
// Sync using official CLI
|
|
39
|
+
await syncMcpServersWithCli(agent, canonicalConfig, isDryRun, mode === 'prune');
|
|
40
|
+
|
|
41
|
+
count++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const category of categories) {
|
|
45
|
+
const itemsToProcess = [];
|
|
46
|
+
|
|
47
|
+
if (actionType === 'sync') {
|
|
48
|
+
itemsToProcess.push(...changeSet[category].missing);
|
|
49
|
+
itemsToProcess.push(...changeSet[category].outdated);
|
|
50
|
+
|
|
51
|
+
// PRUNE: Handle removals from system if no longer in repo
|
|
52
|
+
if (mode === 'prune') {
|
|
53
|
+
for (const itemToDelete of changeSet[category].drifted || []) {
|
|
54
|
+
const dest = path.join(systemRoot, category, itemToDelete);
|
|
55
|
+
console.log(kleur.red(` [x] PRUNING ${category}/${itemToDelete}`));
|
|
56
|
+
if (!isDryRun) await fs.remove(dest);
|
|
57
|
+
count++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else if (actionType === 'backport') {
|
|
61
|
+
itemsToProcess.push(...changeSet[category].drifted);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const item of itemsToProcess) {
|
|
65
|
+
let src, dest;
|
|
66
|
+
|
|
67
|
+
if (category === 'config' && item === 'settings.json' && actionType === 'sync') {
|
|
68
|
+
src = path.join(repoRoot, 'config', 'settings.json');
|
|
69
|
+
dest = path.join(systemRoot, 'settings.json');
|
|
70
|
+
|
|
71
|
+
console.log(kleur.gray(` --> config/settings.json`));
|
|
72
|
+
|
|
73
|
+
// Skip settings.json sync for agents with official MCP CLI
|
|
74
|
+
// MCP servers are managed via CLI, hooks are not supported
|
|
75
|
+
if (agent) {
|
|
76
|
+
console.log(kleur.gray(` (Skipped: ${agent} uses ${agent} mcp CLI for MCP servers)`));
|
|
77
|
+
count++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const repoConfig = await fs.readJson(src);
|
|
82
|
+
let finalRepoConfig = resolveConfigPaths(repoConfig, systemRoot);
|
|
83
|
+
|
|
84
|
+
// Inject Hooks
|
|
85
|
+
const hooksSrc = path.join(repoRoot, 'config', 'hooks.json');
|
|
86
|
+
if (await fs.pathExists(hooksSrc)) {
|
|
87
|
+
const hooksRaw = await fs.readJson(hooksSrc);
|
|
88
|
+
const hooksAdapted = adapter.adaptHooksConfig(hooksRaw);
|
|
89
|
+
if (hooksAdapted.hooks) {
|
|
90
|
+
finalRepoConfig.hooks = { ...(finalRepoConfig.hooks || {}), ...hooksAdapted.hooks };
|
|
91
|
+
if (!isDryRun) console.log(kleur.dim(` (Injected hooks)`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (fs.existsSync(dest)) {
|
|
96
|
+
const localConfig = await fs.readJson(dest);
|
|
97
|
+
const resolvedLocalConfig = resolveConfigPaths(localConfig, systemRoot);
|
|
98
|
+
|
|
99
|
+
// Handle PRUNE mode for mcpServers and hooks
|
|
100
|
+
if (mode === 'prune') {
|
|
101
|
+
// Remove local MCP servers NOT in our canonical source
|
|
102
|
+
if (localConfig.mcpServers && finalRepoConfig.mcpServers) {
|
|
103
|
+
const canonicalServers = new Set(Object.keys(finalRepoConfig.mcpServers));
|
|
104
|
+
for (const serverName of Object.keys(localConfig.mcpServers)) {
|
|
105
|
+
if (!canonicalServers.has(serverName)) {
|
|
106
|
+
delete localConfig.mcpServers[serverName];
|
|
107
|
+
if (!isDryRun) console.log(kleur.red(` (Pruned local MCP server: ${serverName})`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const mergeResult = await safeMergeConfig(dest, finalRepoConfig, {
|
|
114
|
+
backupOnSuccess: true,
|
|
115
|
+
preserveComments: true,
|
|
116
|
+
dryRun: isDryRun,
|
|
117
|
+
resolvedLocalConfig: resolvedLocalConfig
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (mergeResult.updated) {
|
|
121
|
+
console.log(kleur.blue(` (Configuration safely merged)`));
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
if (!isDryRun) {
|
|
125
|
+
await fs.ensureDir(path.dirname(dest));
|
|
126
|
+
await fs.writeJson(dest, finalRepoConfig, { spaces: 2 });
|
|
127
|
+
}
|
|
128
|
+
console.log(kleur.green(` (Created new configuration)`));
|
|
129
|
+
}
|
|
130
|
+
count++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Standard file sync for other items
|
|
135
|
+
const repoPath = category === 'commands' ? path.join(repoRoot, '.gemini', 'commands') :
|
|
136
|
+
category === 'qwen-commands' ? path.join(repoRoot, '.qwen', 'commands') :
|
|
137
|
+
category === 'antigravity-workflows' ? path.join(repoRoot, '.gemini', 'antigravity', 'global_workflows') :
|
|
138
|
+
path.join(repoRoot, category);
|
|
139
|
+
|
|
140
|
+
const systemPath = category === 'qwen-commands' ? path.join(systemRoot, 'commands') :
|
|
141
|
+
category === 'antigravity-workflows' ? path.join(systemRoot, '.gemini', 'antigravity', 'global_workflows') :
|
|
142
|
+
path.join(systemRoot, category);
|
|
143
|
+
|
|
144
|
+
if (actionType === 'backport') {
|
|
145
|
+
src = path.join(systemPath, item);
|
|
146
|
+
dest = path.join(repoPath, item);
|
|
147
|
+
} else {
|
|
148
|
+
src = path.join(repoPath, item);
|
|
149
|
+
dest = path.join(systemPath, item);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log(kleur.gray(` ${actionType === 'backport' ? '<--' : '-->'} ${category}/${item}`));
|
|
153
|
+
|
|
154
|
+
if (mode === 'symlink' && actionType === 'sync' && category !== 'config') {
|
|
155
|
+
if (!isDryRun) {
|
|
156
|
+
await fs.remove(dest);
|
|
157
|
+
await fs.ensureSymlink(src, dest);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
if (!isDryRun) {
|
|
161
|
+
await fs.remove(dest);
|
|
162
|
+
await fs.copy(src, dest);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Gemini Skill -> Command transformation
|
|
167
|
+
if (category === 'skills' && !isClaude && actionType === 'sync') {
|
|
168
|
+
const skillMdPath = path.join(src, 'SKILL.md');
|
|
169
|
+
if (fs.existsSync(skillMdPath)) {
|
|
170
|
+
const result = await transformSkillToCommand(skillMdPath);
|
|
171
|
+
if (result && !isDryRun) {
|
|
172
|
+
const commandDest = path.join(systemRoot, 'commands', `${result.commandName}.toml`);
|
|
173
|
+
await fs.ensureDir(path.dirname(commandDest));
|
|
174
|
+
await fs.writeFile(commandDest, result.toml);
|
|
175
|
+
console.log(kleur.cyan(` (Auto-generated slash command: /${result.commandName})`));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
count++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Final Step: Write Sync Manifest
|
|
185
|
+
if (!isDryRun && actionType === 'sync') {
|
|
186
|
+
const manifestPath = path.join(systemRoot, '.jaggers-sync-manifest.json');
|
|
187
|
+
const manifest = {
|
|
188
|
+
lastSync: new Date().toISOString(),
|
|
189
|
+
repoRoot,
|
|
190
|
+
items: count
|
|
191
|
+
};
|
|
192
|
+
await fs.writeJson(manifestPath, manifest, { spaces: 2 });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return count;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Recursively resolves paths in the config to match the target directory
|
|
200
|
+
*
|
|
201
|
+
* This function corrects hardcoded paths (e.g. /home/dawid/...) to match the current user's home directory.
|
|
202
|
+
* It's applied to both repository config AND local config to ensure existing installations get updated.
|
|
203
|
+
*
|
|
204
|
+
* @param {Object} config - The configuration object to process
|
|
205
|
+
* @param {string} targetDir - The target directory (e.g. /home/jagger/.claude)
|
|
206
|
+
* @returns {Object} - New config object with resolved paths
|
|
207
|
+
*/
|
|
208
|
+
function resolveConfigPaths(config, targetDir) {
|
|
209
|
+
const newConfig = JSON.parse(JSON.stringify(config));
|
|
210
|
+
|
|
211
|
+
function recursiveReplace(obj) {
|
|
212
|
+
for (const key in obj) {
|
|
213
|
+
if (typeof obj[key] === 'string') {
|
|
214
|
+
// Match absolute paths containing /hooks/ and replace the prefix with targetDir/hooks
|
|
215
|
+
if (obj[key].match(/\/[^\s"']+\/hooks\//)) {
|
|
216
|
+
const hooksDir = path.join(targetDir, 'hooks');
|
|
217
|
+
obj[key] = obj[key].replace(/(\/[^\s"']+\/hooks\/)/g, `${hooksDir}/`);
|
|
218
|
+
}
|
|
219
|
+
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
|
|
220
|
+
recursiveReplace(obj[key]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
recursiveReplace(newConfig);
|
|
226
|
+
return newConfig;
|
|
227
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transform Claude settings.json to Gemini-compatible format
|
|
5
|
+
*/
|
|
6
|
+
export function transformGeminiConfig(claudeConfig, targetDir) {
|
|
7
|
+
const geminiConfig = {
|
|
8
|
+
hooks: {}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
if (claudeConfig.hooks) {
|
|
12
|
+
for (const [event, hooks] of Object.entries(claudeConfig.hooks)) {
|
|
13
|
+
const geminiEvent = mapEventName(event);
|
|
14
|
+
if (!geminiEvent) continue;
|
|
15
|
+
geminiConfig.hooks[geminiEvent] = hooks.map(def => transformHookDefinition(def, targetDir));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return geminiConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mapEventName(claudeEvent) {
|
|
23
|
+
const map = {
|
|
24
|
+
'UserPromptSubmit': 'BeforeAgent',
|
|
25
|
+
'PreToolUse': 'BeforeTool',
|
|
26
|
+
'SessionStart': 'SessionStart',
|
|
27
|
+
};
|
|
28
|
+
return map[claudeEvent] || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function transformHookDefinition(claudeDef, targetDir) {
|
|
32
|
+
const geminiDef = {
|
|
33
|
+
hooks: []
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (claudeDef.matcher) {
|
|
37
|
+
let matcher = claudeDef.matcher;
|
|
38
|
+
const toolMap = {
|
|
39
|
+
'Read': 'read_file',
|
|
40
|
+
'Write': 'write_file',
|
|
41
|
+
'Edit': 'replace',
|
|
42
|
+
'Bash': 'run_shell_command'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
for (const [claudeTool, geminiTool] of Object.entries(toolMap)) {
|
|
46
|
+
const regex = new RegExp(`\\b${claudeTool}\\b`, 'g');
|
|
47
|
+
matcher = matcher.replace(regex, geminiTool);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
geminiDef.matcher = matcher;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
geminiDef.hooks = claudeDef.hooks.map((h, index) => {
|
|
54
|
+
const cmd = h.command;
|
|
55
|
+
let newCommand = cmd;
|
|
56
|
+
if (targetDir) {
|
|
57
|
+
const claudePathRegex = /(\/[^\s"']+\.claude)/g;
|
|
58
|
+
newCommand = newCommand.replace(claudePathRegex, (match) => {
|
|
59
|
+
return targetDir;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
name: h.name || `generated-hook-${index}`,
|
|
65
|
+
type: "command",
|
|
66
|
+
command: newCommand,
|
|
67
|
+
timeout: h.timeout || 60000
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return geminiDef;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Transform a SKILL.md file into a Gemini command .toml content
|
|
76
|
+
*/
|
|
77
|
+
export async function transformSkillToCommand(skillMdPath) {
|
|
78
|
+
try {
|
|
79
|
+
const content = await fs.readFile(skillMdPath, 'utf8');
|
|
80
|
+
|
|
81
|
+
// Extract frontmatter
|
|
82
|
+
const frontmatterMatch = content.match(/^---([\s\S]+?)---/);
|
|
83
|
+
if (!frontmatterMatch) return null;
|
|
84
|
+
|
|
85
|
+
const frontmatter = frontmatterMatch[1];
|
|
86
|
+
|
|
87
|
+
// Extract required and optional fields
|
|
88
|
+
const nameMatch = frontmatter.match(/name:\s*(.+)/);
|
|
89
|
+
const descMatch = frontmatter.match(/description:\s*(.+)/);
|
|
90
|
+
const geminiCmdMatch = frontmatter.match(/gemini-command:\s*(.+)/);
|
|
91
|
+
const geminiPromptMatch = frontmatter.match(/gemini-prompt:\s*\|?\s*\n?([\s\S]+?)(?=\n[a-z- ]+:|$)/);
|
|
92
|
+
|
|
93
|
+
if (!nameMatch || !descMatch) return null;
|
|
94
|
+
|
|
95
|
+
const name = nameMatch[1].trim();
|
|
96
|
+
const description = descMatch[1].trim();
|
|
97
|
+
const commandName = geminiCmdMatch ? geminiCmdMatch[1].trim() : name;
|
|
98
|
+
|
|
99
|
+
let promptBody = `Use the ${name} skill to handle this: {{args}}`;
|
|
100
|
+
if (geminiPromptMatch) {
|
|
101
|
+
// Indent the extra prompt lines properly if they aren't already
|
|
102
|
+
const extraLines = geminiPromptMatch[1].trim();
|
|
103
|
+
promptBody = `Use the ${name} skill to handle this request: {{args}}\n\n${extraLines}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const toml = `description = """${description}"""
|
|
107
|
+
prompt = """
|
|
108
|
+
${promptBody}
|
|
109
|
+
"""
|
|
110
|
+
`;
|
|
111
|
+
return {
|
|
112
|
+
toml,
|
|
113
|
+
commandName
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`Error transforming skill to command: ${error.message}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|