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.
Files changed (50) hide show
  1. package/.gemini/settings.json +39 -0
  2. package/dist/index.cjs +55937 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2 -0
  5. package/index.js +151 -0
  6. package/lib/atomic-config.js +236 -0
  7. package/lib/config-adapter.js +231 -0
  8. package/lib/config-injector.js +80 -0
  9. package/lib/context.js +73 -0
  10. package/lib/diff.js +142 -0
  11. package/lib/env-manager.js +160 -0
  12. package/lib/sync-mcp-cli.js +345 -0
  13. package/lib/sync.js +227 -0
  14. package/lib/transform-gemini.js +119 -0
  15. package/package.json +43 -0
  16. package/src/adapters/base.ts +29 -0
  17. package/src/adapters/claude.ts +38 -0
  18. package/src/adapters/registry.ts +21 -0
  19. package/src/commands/help.ts +171 -0
  20. package/src/commands/install-project.ts +566 -0
  21. package/src/commands/install-service-skills.ts +251 -0
  22. package/src/commands/install.ts +534 -0
  23. package/src/commands/reset.ts +12 -0
  24. package/src/commands/status.ts +170 -0
  25. package/src/core/context.ts +141 -0
  26. package/src/core/diff.ts +143 -0
  27. package/src/core/interactive-plan.ts +165 -0
  28. package/src/core/manifest.ts +26 -0
  29. package/src/core/preflight.ts +142 -0
  30. package/src/core/rollback.ts +32 -0
  31. package/src/core/sync-executor.ts +399 -0
  32. package/src/index.ts +69 -0
  33. package/src/types/config.ts +51 -0
  34. package/src/types/models.ts +52 -0
  35. package/src/utils/atomic-config.ts +222 -0
  36. package/src/utils/banner.ts +194 -0
  37. package/src/utils/config-adapter.ts +90 -0
  38. package/src/utils/config-injector.ts +81 -0
  39. package/src/utils/env-manager.ts +193 -0
  40. package/src/utils/hash.ts +42 -0
  41. package/src/utils/repo-root.ts +39 -0
  42. package/src/utils/sync-mcp-cli.ts +467 -0
  43. package/src/utils/theme.ts +37 -0
  44. package/test/context.test.ts +33 -0
  45. package/test/hooks.test.ts +277 -0
  46. package/test/install-project.test.ts +235 -0
  47. package/test/install-service-skills.test.ts +111 -0
  48. package/tsconfig.json +22 -0
  49. package/tsup.config.ts +17 -0
  50. 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
+ }