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,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>;
|