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,142 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+ import fs from 'fs-extra';
4
+ import { spawnSync } from 'child_process';
5
+ import { calculateDiff } from './diff.js';
6
+ import {
7
+ loadCanonicalMcpConfig,
8
+ getCurrentServers,
9
+ detectAgent,
10
+ } from '../utils/sync-mcp-cli.js';
11
+ import type { ChangeSet, ChangeSetCategory } from '../types/config.js';
12
+ import type { AgentName } from '../utils/sync-mcp-cli.js';
13
+
14
+ // ── Types ──────────────────────────────────────────────────────────────────
15
+
16
+ export interface FileItem {
17
+ name: string;
18
+ status: 'missing' | 'outdated' | 'drifted';
19
+ category: string;
20
+ }
21
+
22
+ export interface McpItem {
23
+ name: string;
24
+ installed: boolean;
25
+ }
26
+
27
+ export interface TargetPlan {
28
+ target: string;
29
+ label: string;
30
+ agent: string | null;
31
+ files: FileItem[];
32
+ mcpCore: McpItem[];
33
+ changeSet: ChangeSet;
34
+ }
35
+
36
+ export interface OptionalServerItem {
37
+ name: string;
38
+ description: string;
39
+ prerequisite?: string;
40
+ installCmd?: string;
41
+ postInstallMessage?: string;
42
+ }
43
+
44
+ export interface PreflightPlan {
45
+ targets: TargetPlan[];
46
+ optionalServers: OptionalServerItem[];
47
+ repoRoot: string;
48
+ syncMode: 'copy' | 'symlink' | 'prune';
49
+ }
50
+
51
+ // ── Helpers ────────────────────────────────────────────────────────────────
52
+
53
+ function getCandidatePaths(): Array<{ label: string; path: string }> {
54
+ const home = os.homedir();
55
+ return [
56
+ { label: '~/.claude (hooks + skills)', path: path.join(home, '.claude') },
57
+ { label: '.qwen', path: path.join(home, '.qwen') },
58
+ { label: '~/.agents/skills', path: path.join(home, '.agents', 'skills') },
59
+ ];
60
+ }
61
+
62
+ export function isBinaryAvailable(binary: string): boolean {
63
+ // Uses spawnSync (not shell exec) to avoid shell injection; binary is always
64
+ // a hard-coded internal constant, never user-supplied input.
65
+ const result = spawnSync('which', [binary], { stdio: 'pipe' });
66
+ return result.status === 0;
67
+ }
68
+
69
+ // ── Main export ────────────────────────────────────────────────────────────
70
+
71
+ export async function runPreflight(
72
+ repoRoot: string,
73
+ prune = false
74
+ ): Promise<PreflightPlan> {
75
+ const candidates = getCandidatePaths();
76
+
77
+ // Fix 4: Hoist canonical MCP config load outside the per-target map (read once, not N times)
78
+ const canonicalMcp = loadCanonicalMcpConfig(repoRoot);
79
+
80
+ // Run all target checks in parallel
81
+ const targetResults = await Promise.all(
82
+ candidates.map(async (c) => {
83
+ // Fix 1: Per-target error isolation — one bad target doesn't abort the whole preflight
84
+ try {
85
+ const exists = await fs.pathExists(c.path);
86
+ if (!exists) return null;
87
+
88
+ const agent = detectAgent(c.path);
89
+
90
+ const changeSet = await calculateDiff(repoRoot, c.path, prune);
91
+
92
+ // Fix 3: Use proper ChangeSetCategory type cast instead of `cat as any`
93
+ const files: FileItem[] = [];
94
+ for (const [category, cat] of Object.entries(changeSet) as [string, ChangeSetCategory][]) {
95
+ for (const name of cat.missing) files.push({ name, status: 'missing', category });
96
+ for (const name of cat.outdated) files.push({ name, status: 'outdated', category });
97
+ for (const name of cat.drifted) files.push({ name, status: 'drifted', category });
98
+ }
99
+
100
+ const installedMcp = agent ? getCurrentServers(agent) : [];
101
+ const mcpCore: McpItem[] = Object.keys(canonicalMcp.mcpServers || {}).map(name => ({
102
+ name,
103
+ installed: installedMcp.includes(name),
104
+ }));
105
+
106
+ return { target: c.path, label: c.label, agent, files, mcpCore, changeSet };
107
+ } catch (err) {
108
+ console.warn(`⚠ Preflight skipped ${c.label}: ${(err as Error).message}`);
109
+ return null;
110
+ }
111
+ })
112
+ );
113
+
114
+ const targets = targetResults.filter((t): t is TargetPlan => t !== null);
115
+
116
+ // Fix 2: Gather all actually installed MCP servers (across all agents, core + optional)
117
+ // allInstalledMcp previously only contained core server names from mcpCore — optional servers
118
+ // installed via CLI were never excluded from the optionalServers list.
119
+ const allInstalledMcp = new Set<string>();
120
+ for (const t of targets) {
121
+ if (t.agent) {
122
+ const installed = getCurrentServers(t.agent as AgentName);
123
+ for (const name of installed) allInstalledMcp.add(name);
124
+ }
125
+ }
126
+
127
+ // Load optional servers config
128
+ const optionalConfig = loadCanonicalMcpConfig(repoRoot, true);
129
+
130
+ const optionalServers: OptionalServerItem[] = Object.entries(optionalConfig.mcpServers || {})
131
+ .filter(([name]) => !allInstalledMcp.has(name))
132
+ .map(([name, server]: [string, any]) => ({
133
+ name,
134
+ description: server._notes?.description || '',
135
+ prerequisite: server._notes?.prerequisite,
136
+ installCmd: server._notes?.install_cmd,
137
+ postInstallMessage: server._notes?.post_install_message,
138
+ }));
139
+
140
+ const syncMode: 'copy' | 'symlink' | 'prune' = prune ? 'prune' : 'copy';
141
+ return { targets, optionalServers, repoRoot, syncMode };
142
+ }
@@ -0,0 +1,32 @@
1
+ import fs from 'fs-extra';
2
+
3
+ export interface BackupInfo {
4
+ originalPath: string;
5
+ backupPath: string;
6
+ timestamp: Date;
7
+ }
8
+
9
+ export async function createBackup(filePath: string): Promise<BackupInfo> {
10
+ const timestamp = Date.now();
11
+ const backupPath = `${filePath}.backup-${timestamp}`;
12
+
13
+ if (await fs.pathExists(filePath)) {
14
+ await fs.copy(filePath, backupPath);
15
+ }
16
+
17
+ return {
18
+ originalPath: filePath,
19
+ backupPath,
20
+ timestamp: new Date(),
21
+ };
22
+ }
23
+
24
+ export async function restoreBackup(backup: BackupInfo): Promise<void> {
25
+ if (await fs.pathExists(backup.backupPath)) {
26
+ await fs.move(backup.backupPath, backup.originalPath, { overwrite: true });
27
+ }
28
+ }
29
+
30
+ export async function cleanupBackup(backup: BackupInfo): Promise<void> {
31
+ await fs.remove(backup.backupPath);
32
+ }
@@ -0,0 +1,399 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import kleur from 'kleur';
4
+ import { safeMergeConfig } from '../utils/atomic-config.js';
5
+ import { ConfigAdapter } from '../utils/config-adapter.js';
6
+ import { syncMcpServersWithCli, loadCanonicalMcpConfig, detectAgent } from '../utils/sync-mcp-cli.js';
7
+ import { createBackup, restoreBackup, cleanupBackup, type BackupInfo } from './rollback.js';
8
+ import type { ChangeSet } from '../types/config.js';
9
+
10
+ /**
11
+ * Execute a sync plan based on changeset and mode
12
+ */
13
+ // Track which MCP agents have been synced in this process run to prevent duplicate syncs
14
+ const syncedMcpAgents = new Set<string>();
15
+
16
+ function extractHookCommandPath(command: string): string | null {
17
+ const quoted = command.match(/"([^"]+)"/);
18
+ if (quoted?.[1]) return quoted[1];
19
+
20
+ const singleQuoted = command.match(/'([^']+)'/);
21
+ if (singleQuoted?.[1]) return singleQuoted[1];
22
+
23
+ const bare = command.trim().split(/\s+/).slice(1).join(' ').trim();
24
+ return bare || null;
25
+ }
26
+
27
+ async function filterHooksByInstalledScripts(hooksConfig: any): Promise<any> {
28
+ if (!hooksConfig || typeof hooksConfig !== 'object' || !hooksConfig.hooks) {
29
+ return hooksConfig;
30
+ }
31
+
32
+ for (const [event, wrappers] of Object.entries(hooksConfig.hooks)) {
33
+ if (!Array.isArray(wrappers)) continue;
34
+
35
+ const keptWrappers: any[] = [];
36
+ for (const wrapper of wrappers) {
37
+ if (!wrapper || !Array.isArray(wrapper.hooks)) continue;
38
+
39
+ const keptInner: any[] = [];
40
+ for (const inner of wrapper.hooks) {
41
+ const command = inner?.command;
42
+ if (typeof command !== 'string' || !command.trim()) continue;
43
+
44
+ const scriptPath = extractHookCommandPath(command);
45
+ if (!scriptPath) continue;
46
+
47
+ if (await fs.pathExists(scriptPath)) {
48
+ keptInner.push(inner);
49
+ }
50
+ }
51
+
52
+ if (keptInner.length > 0) {
53
+ keptWrappers.push({ ...wrapper, hooks: keptInner });
54
+ }
55
+ }
56
+
57
+ hooksConfig.hooks[event] = keptWrappers;
58
+ }
59
+
60
+ return hooksConfig;
61
+ }
62
+
63
+ export async function executeSync(
64
+ repoRoot: string,
65
+ systemRoot: string,
66
+ changeSet: ChangeSet,
67
+ mode: 'copy' | 'symlink' | 'prune',
68
+ actionType: 'sync' | 'backport',
69
+ isDryRun: boolean = false,
70
+ selectedMcpServers?: string[],
71
+ options?: { skipMcp?: boolean; force?: boolean },
72
+ ): Promise<number> {
73
+ const normalizedRoot = path.normalize(systemRoot).replace(/\\/g, '/');
74
+ const isAgentsSkills = normalizedRoot.includes('.agents/skills');
75
+ const isClaude = systemRoot.includes('.claude') || systemRoot.includes('Claude');
76
+
77
+ // ~/.agents/skills: skills-only, written directly into systemRoot (no subdirectory)
78
+ if (isAgentsSkills) {
79
+ return executeSyncAgentsSkills(repoRoot, systemRoot, changeSet, mode, actionType, isDryRun);
80
+ }
81
+
82
+ const categories: Array<keyof ChangeSet> = ['skills', 'hooks', 'config'];
83
+
84
+
85
+ let count = 0;
86
+ const adapter = new ConfigAdapter(systemRoot);
87
+ const backups: BackupInfo[] = [];
88
+
89
+ try {
90
+ const agent = detectAgent(systemRoot);
91
+
92
+ // Only sync MCP once per unique agent type per process run.
93
+ // Without this guard, selecting multiple Claude config directories causes
94
+ // syncMcpServersWithCli to fire 3 times with identical output.
95
+ if (agent && actionType === 'sync' && !syncedMcpAgents.has(agent) && !options?.skipMcp) {
96
+ const coreConfig = loadCanonicalMcpConfig(repoRoot);
97
+
98
+ // Build MCP config: core servers always + any pre-selected optionals
99
+ const mcpToSync: any = { mcpServers: { ...coreConfig.mcpServers } };
100
+
101
+ if (selectedMcpServers && selectedMcpServers.length > 0) {
102
+ const optionalConfig = loadCanonicalMcpConfig(repoRoot, true);
103
+ for (const name of selectedMcpServers) {
104
+ if (optionalConfig.mcpServers[name]) {
105
+ mcpToSync.mcpServers[name] = optionalConfig.mcpServers[name];
106
+ }
107
+ }
108
+ }
109
+
110
+ if (!isDryRun) {
111
+ await syncMcpServersWithCli(agent, mcpToSync, isDryRun, false);
112
+ } else {
113
+ console.log(kleur.cyan(` [DRY RUN] MCP sync for ${agent}`));
114
+ }
115
+ syncedMcpAgents.add(agent);
116
+ count++;
117
+ }
118
+
119
+ for (const category of categories) {
120
+ const itemsToProcess: string[] = [];
121
+
122
+ if (actionType === 'sync') {
123
+ const cat = changeSet[category] as any;
124
+ itemsToProcess.push(...cat.missing);
125
+ itemsToProcess.push(...cat.outdated);
126
+
127
+ if (options?.force) {
128
+ itemsToProcess.push(...cat.drifted);
129
+ }
130
+
131
+ if (mode === 'prune') {
132
+ for (const itemToDelete of cat.drifted || []) {
133
+ const dest = path.join(systemRoot, category, itemToDelete);
134
+ console.log(kleur.red(` [x] PRUNING ${category}/${itemToDelete}`));
135
+ if (!isDryRun) {
136
+ if (await fs.pathExists(dest)) {
137
+ backups.push(await createBackup(dest));
138
+ await fs.remove(dest);
139
+ }
140
+ }
141
+ count++;
142
+ }
143
+ }
144
+ } else if (actionType === 'backport') {
145
+ const cat = changeSet[category] as any;
146
+ itemsToProcess.push(...cat.drifted);
147
+ }
148
+
149
+ for (const item of itemsToProcess) {
150
+ let src: string, dest: string;
151
+
152
+ if (category === 'config' && item === 'settings.json' && actionType === 'sync') {
153
+ src = path.join(repoRoot, 'config', 'settings.json');
154
+ dest = path.join(systemRoot, 'settings.json');
155
+
156
+ const agent = detectAgent(systemRoot);
157
+
158
+ console.log(kleur.gray(` --> config/settings.json`));
159
+
160
+ if (!isDryRun && await fs.pathExists(dest)) {
161
+ backups.push(await createBackup(dest));
162
+ }
163
+
164
+ const repoConfig = await fs.readJson(src);
165
+ let finalRepoConfig = resolveConfigPaths(repoConfig, systemRoot);
166
+
167
+ // When agent CLI handles MCP servers, strip them from the config merge
168
+ // but still merge hooks, permissions, plugins, skillSuggestions, etc.
169
+ if (agent) {
170
+ delete finalRepoConfig.mcpServers;
171
+ if (!isDryRun) console.log(kleur.dim(` (MCP servers managed by ${agent} CLI — merging non-MCP settings only)`));
172
+ }
173
+
174
+ const hooksSrc = path.join(repoRoot, 'config', 'hooks.json');
175
+ if (await fs.pathExists(hooksSrc)) {
176
+ const hooksRaw = await fs.readJson(hooksSrc);
177
+ const hooksAdapted = await filterHooksByInstalledScripts(adapter.adaptHooksConfig(hooksRaw));
178
+ if (hooksAdapted.hooks) {
179
+ // hooks.json is the canonical source — replace template hooks entirely
180
+ // to avoid Claude event names leaking into Gemini/Qwen configs
181
+ finalRepoConfig.hooks = hooksAdapted.hooks;
182
+ if (!isDryRun) console.log(kleur.dim(` (Injected hooks)`));
183
+ }
184
+ if (hooksAdapted.statusLine) {
185
+ finalRepoConfig.statusLine = hooksAdapted.statusLine;
186
+ }
187
+ }
188
+
189
+ if (fs.existsSync(dest)) {
190
+ const localConfig = await fs.readJson(dest);
191
+ const resolvedLocalConfig = resolveConfigPaths(localConfig, systemRoot);
192
+
193
+ if (mode === 'prune') {
194
+ if (localConfig.mcpServers && finalRepoConfig.mcpServers) {
195
+ const canonicalServers = new Set(Object.keys(finalRepoConfig.mcpServers));
196
+ for (const serverName of Object.keys(localConfig.mcpServers)) {
197
+ if (!canonicalServers.has(serverName)) {
198
+ delete localConfig.mcpServers[serverName];
199
+ if (!isDryRun) console.log(kleur.red(` (Pruned local MCP server: ${serverName})`));
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ const mergeResult = await safeMergeConfig(dest, finalRepoConfig, {
206
+ backupOnSuccess: false,
207
+ preserveComments: true,
208
+ dryRun: isDryRun,
209
+ resolvedLocalConfig: resolvedLocalConfig
210
+ });
211
+
212
+ if (mergeResult.updated) {
213
+ console.log(kleur.blue(` (Configuration safely merged)`));
214
+ }
215
+ } else {
216
+ if (!isDryRun) {
217
+ await fs.ensureDir(path.dirname(dest));
218
+ await fs.writeJson(dest, finalRepoConfig, { spaces: 2 });
219
+ }
220
+ console.log(kleur.green(` (Created new configuration)`));
221
+ }
222
+ count++;
223
+ continue;
224
+ }
225
+
226
+ const repoPath = category === 'commands' ? path.join(repoRoot, '.gemini', 'commands') :
227
+ category === 'qwen-commands' ? path.join(repoRoot, '.qwen', 'commands') :
228
+ category === 'antigravity-workflows' ? path.join(repoRoot, '.gemini', 'antigravity', 'global_workflows') :
229
+ path.join(repoRoot, category);
230
+
231
+ const systemPath = category === 'qwen-commands' ? path.join(systemRoot, 'commands') :
232
+ category === 'antigravity-workflows' ? path.join(systemRoot, '.gemini', 'antigravity', 'global_workflows') :
233
+ path.join(systemRoot, category);
234
+
235
+ if (actionType === 'backport') {
236
+ src = path.join(systemPath, item);
237
+ dest = path.join(repoPath, item);
238
+ } else {
239
+ src = path.join(repoPath, item);
240
+ dest = path.join(systemPath, item);
241
+ }
242
+
243
+ console.log(kleur.gray(` ${actionType === 'backport' ? '<--' : '-->'} ${category}/${item}`));
244
+
245
+ if (!isDryRun && actionType === 'sync' && await fs.pathExists(dest)) {
246
+ backups.push(await createBackup(dest));
247
+ }
248
+
249
+ if (mode === 'symlink' && actionType === 'sync' && category !== 'config') {
250
+ if (!isDryRun) {
251
+ if (process.platform === 'win32') {
252
+ console.log(kleur.yellow(' ⚠ Symlinks require Developer Mode on Windows — falling back to copy.'));
253
+ await fs.remove(dest);
254
+ await fs.copy(src, dest);
255
+ } else {
256
+ await fs.remove(dest);
257
+ await fs.ensureSymlink(src, dest);
258
+ }
259
+ }
260
+ } else {
261
+ if (!isDryRun) {
262
+ await fs.remove(dest);
263
+ await fs.copy(src, dest);
264
+ }
265
+ }
266
+
267
+ count++;
268
+ }
269
+ }
270
+
271
+ if (!isDryRun && actionType === 'sync') {
272
+ const manifestPath = path.join(systemRoot, '.jaggers-sync-manifest.json');
273
+ const existing = await fs.pathExists(manifestPath)
274
+ ? await fs.readJson(manifestPath)
275
+ : {};
276
+ await fs.writeJson(manifestPath, {
277
+ ...existing,
278
+ lastSync: new Date().toISOString(),
279
+ repoRoot,
280
+ items: count
281
+ }, { spaces: 2 });
282
+ }
283
+
284
+ for (const backup of backups) {
285
+ await cleanupBackup(backup);
286
+ }
287
+
288
+ return count;
289
+
290
+ } catch (error: any) {
291
+ console.error(kleur.red(`\nSync failed, rolling back ${backups.length} changes...`));
292
+ for (const backup of backups) {
293
+ try {
294
+ await restoreBackup(backup);
295
+ } finally {
296
+ await cleanupBackup(backup);
297
+ }
298
+ }
299
+ throw error;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Sync skills directly into ~/.agents/skills/<skill> (no subdirectory indirection).
305
+ * This target is skills-only — no hooks, config, MCP, or commands.
306
+ */
307
+ async function executeSyncAgentsSkills(
308
+ repoRoot: string,
309
+ systemRoot: string,
310
+ changeSet: ChangeSet,
311
+ mode: 'copy' | 'symlink' | 'prune',
312
+ actionType: 'sync' | 'backport',
313
+ isDryRun: boolean,
314
+ ): Promise<number> {
315
+ let count = 0;
316
+ const backups: BackupInfo[] = [];
317
+
318
+ try {
319
+ const repoSkillsPath = path.join(repoRoot, 'skills');
320
+ const itemsToProcess: string[] = [];
321
+
322
+ if (actionType === 'sync') {
323
+ itemsToProcess.push(...changeSet.skills.missing, ...changeSet.skills.outdated);
324
+ } else if (actionType === 'backport') {
325
+ itemsToProcess.push(...changeSet.skills.drifted);
326
+ }
327
+
328
+ for (const item of itemsToProcess) {
329
+ const src = actionType === 'backport'
330
+ ? path.join(systemRoot, item)
331
+ : path.join(repoSkillsPath, item);
332
+ const dest = actionType === 'backport'
333
+ ? path.join(repoSkillsPath, item)
334
+ : path.join(systemRoot, item);
335
+
336
+ console.log(kleur.gray(` ${actionType === 'backport' ? '<--' : '-->'} ${item}`));
337
+
338
+ if (!isDryRun) {
339
+ if (await fs.pathExists(dest)) backups.push(await createBackup(dest));
340
+ await fs.ensureDir(path.dirname(dest));
341
+
342
+ if (mode === 'symlink' && actionType === 'sync') {
343
+ if (process.platform === 'win32') {
344
+ console.log(kleur.yellow(' ⚠ Symlinks require Developer Mode on Windows — falling back to copy.'));
345
+ await fs.remove(dest);
346
+ await fs.copy(src, dest);
347
+ } else {
348
+ await fs.remove(dest);
349
+ await fs.ensureSymlink(src, dest);
350
+ }
351
+ } else {
352
+ await fs.remove(dest);
353
+ await fs.copy(src, dest);
354
+ }
355
+ }
356
+ count++;
357
+ }
358
+
359
+ for (const backup of backups) await cleanupBackup(backup);
360
+ return count;
361
+
362
+ } catch (error: any) {
363
+ console.error(kleur.red(`\nSync failed, rolling back ${backups.length} changes...`));
364
+ for (const backup of backups) {
365
+ try { await restoreBackup(backup); } finally { await cleanupBackup(backup); }
366
+ }
367
+ throw error;
368
+ }
369
+ }
370
+
371
+ function resolveConfigPaths(config: any, targetDir: string): any {
372
+ const newConfig = JSON.parse(JSON.stringify(config));
373
+ const home = process.env.HOME || process.env.USERPROFILE || '';
374
+
375
+ function recursiveReplace(obj: any) {
376
+ for (const key in obj) {
377
+ if (typeof obj[key] === 'string') {
378
+ let val = obj[key];
379
+ // Resolve $HOME and ~ to actual home directory for comparison
380
+ // but only match absolute paths, not env-var paths like "$HOME/..."
381
+ if (!val.startsWith('$') && !val.startsWith('~')) {
382
+ if (val.match(/\/[^\s"']+\/hooks\//)) {
383
+ const hooksDir = path.join(targetDir, 'hooks');
384
+ let replacementDir = `${hooksDir}/`;
385
+ if (process.platform === 'win32') {
386
+ replacementDir = replacementDir.replace(/\\/g, '/');
387
+ }
388
+ obj[key] = val.replace(/(\/[^\s"']+\/hooks\/)/g, replacementDir);
389
+ }
390
+ }
391
+ } else if (typeof obj[key] === 'object' && obj[key] !== null) {
392
+ recursiveReplace(obj[key]);
393
+ }
394
+ }
395
+ }
396
+
397
+ recursiveReplace(newConfig);
398
+ return newConfig;
399
+ }
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import kleur from 'kleur';
5
+
6
+ // __dirname is available in CJS output (tsup target: cjs)
7
+ declare const __dirname: string;
8
+ let version = '0.0.0';
9
+ try { version = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')).version; } catch { /* fallback */ }
10
+
11
+ import { createInstallCommand } from './commands/install.js';
12
+ import { createProjectCommand } from './commands/install-project.js';
13
+ import { createStatusCommand } from './commands/status.js';
14
+ import { createResetCommand } from './commands/reset.js';
15
+ import { createHelpCommand } from './commands/help.js';
16
+ import { printBanner } from './utils/banner.js';
17
+
18
+ const program = new Command();
19
+
20
+ program
21
+ .name('xtrm')
22
+ .description('Claude Code tools installer (skills, hooks, MCP servers)')
23
+ .version(version);
24
+
25
+ // Add exit override for cleaner unknown command error
26
+ program.exitOverride((err) => {
27
+ if (err.code === 'commander.unknownCommand') {
28
+ console.error(kleur.red(`\n✗ Unknown command. Run 'xtrm --help'\n`));
29
+ process.exit(1);
30
+ }
31
+ process.exit(1);
32
+ });
33
+
34
+ // Main commands
35
+ program.addCommand(createInstallCommand());
36
+ program.addCommand(createProjectCommand());
37
+ program.addCommand(createStatusCommand());
38
+ program.addCommand(createResetCommand());
39
+ program.addCommand(createHelpCommand());
40
+
41
+ // Default action: show help
42
+ program
43
+ .action(async () => {
44
+ program.help();
45
+ });
46
+
47
+ // Global error handlers
48
+ process.on('uncaughtException', (err) => {
49
+ if ((err as any).code?.startsWith('commander.')) {
50
+ return;
51
+ }
52
+ console.error(kleur.red(`\n✗ ${err.message}\n`));
53
+ process.exit(1);
54
+ });
55
+
56
+ process.on('unhandledRejection', (reason) => {
57
+ console.error(kleur.red(`\n✗ ${String(reason)}\n`));
58
+ process.exit(1);
59
+ });
60
+
61
+ // Show startup banner unless --help or --version flag is present
62
+ const isHelpOrVersion = process.argv.some(a => a === '--help' || a === '-h' || a === '--version' || a === '-V');
63
+
64
+ (async () => {
65
+ if (!isHelpOrVersion) {
66
+ await printBanner(version);
67
+ }
68
+ program.parseAsync(process.argv);
69
+ })();
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+
3
+ export const SyncModeSchema = z.enum(['copy', 'symlink', 'prune']);
4
+ export type SyncMode = z.infer<typeof SyncModeSchema>;
5
+
6
+ export const TargetConfigSchema = z.object({
7
+ label: z.string(),
8
+ path: z.string(),
9
+ exists: z.boolean(),
10
+ });
11
+ export type TargetConfig = z.infer<typeof TargetConfigSchema>;
12
+
13
+ export const ChangeSetCategorySchema = z.object({
14
+ missing: z.array(z.string()),
15
+ outdated: z.array(z.string()),
16
+ drifted: z.array(z.string()),
17
+ total: z.number(),
18
+ });
19
+ export type ChangeSetCategory = z.infer<typeof ChangeSetCategorySchema>;
20
+
21
+ export const ChangeSetSchema = z.object({
22
+ skills: ChangeSetCategorySchema,
23
+ hooks: ChangeSetCategorySchema,
24
+ config: ChangeSetCategorySchema,
25
+ commands: ChangeSetCategorySchema,
26
+ 'qwen-commands': ChangeSetCategorySchema,
27
+ 'antigravity-workflows': ChangeSetCategorySchema,
28
+ });
29
+ export type ChangeSet = z.infer<typeof ChangeSetSchema>;
30
+
31
+ export const SyncPlanSchema = z.object({
32
+ mode: SyncModeSchema,
33
+ targets: z.array(z.string()),
34
+ });
35
+ export type SyncPlan = z.infer<typeof SyncPlanSchema>;
36
+
37
+ export const ManifestItemSchema = z.object({
38
+ type: z.enum(['skill', 'hook', 'config', 'command']),
39
+ name: z.string(),
40
+ hash: z.string(),
41
+ lastSync: z.string(),
42
+ source: z.string(),
43
+ });
44
+ export type ManifestItem = z.infer<typeof ManifestItemSchema>;
45
+
46
+ export const ManifestSchema = z.object({
47
+ version: z.string().optional().default('1'),
48
+ lastSync: z.string(),
49
+ items: z.number().optional().default(0),
50
+ });
51
+ export type Manifest = z.infer<typeof ManifestSchema>;