xtrm-cli 0.5.0
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 +57378 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/extensions/beads.ts +109 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/custom-footer.ts +160 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/minimal-mode.ts +201 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +150 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/hooks/gitnexus-impact-reminder.py +13 -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/package.json +47 -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/claude.ts +122 -0
- package/src/commands/clean.ts +371 -0
- package/src/commands/end.ts +239 -0
- package/src/commands/finish.ts +25 -0
- package/src/commands/help.ts +180 -0
- package/src/commands/init.ts +959 -0
- package/src/commands/install-pi.ts +276 -0
- package/src/commands/install-service-skills.ts +281 -0
- package/src/commands/install.ts +427 -0
- package/src/commands/pi-install.ts +119 -0
- package/src/commands/pi.ts +128 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/commands/worktree.ts +193 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +174 -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/session-state.ts +139 -0
- package/src/core/sync-executor.ts +427 -0
- package/src/core/xtrm-finish.ts +267 -0
- package/src/index.ts +87 -0
- package/src/tests/policy-parity.test.ts +204 -0
- package/src/tests/session-flow-parity.test.ts +118 -0
- package/src/tests/session-state.test.ts +124 -0
- package/src/tests/xtrm-finish.test.ts +148 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +467 -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 +395 -0
- package/src/utils/theme.ts +37 -0
- package/src/utils/worktree-session.ts +93 -0
- package/test/atomic-config-prune.test.ts +101 -0
- package/test/atomic-config.test.ts +138 -0
- package/test/clean.test.ts +172 -0
- package/test/config-schema.test.ts +52 -0
- package/test/context.test.ts +33 -0
- package/test/end-worktree.test.ts +168 -0
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/minimal-mode.test.ts +107 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/service-skills.test.ts +84 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/hooks/quality-check-hooks.test.ts +45 -0
- package/test/hooks.test.ts +1075 -0
- package/test/install-pi.test.ts +185 -0
- package/test/install-project.test.ts +378 -0
- package/test/install-service-skills.test.ts +131 -0
- package/test/install-surface.test.ts +72 -0
- package/test/runtime-subcommands.test.ts +121 -0
- package/test/session-launcher.test.ts +139 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { parse, stringify } from 'comment-json';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Atomic Configuration Handler with Vault Pattern
|
|
7
|
+
* Ensures safe read/write operations with protection against corruption during crashes
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Protected keys that should never be overwritten if they exist locally
|
|
11
|
+
const PROTECTED_KEYS = [
|
|
12
|
+
'permissions.allow', // User-defined permissions
|
|
13
|
+
'hooks.UserPromptSubmit', // Claude hooks
|
|
14
|
+
'hooks.SessionStart',
|
|
15
|
+
'hooks.PreToolUse',
|
|
16
|
+
'hooks.BeforeAgent', // Gemini hooks
|
|
17
|
+
'hooks.BeforeTool', // Gemini hooks
|
|
18
|
+
'security', // Auth secrets/OAuth data
|
|
19
|
+
'general', // Personal preferences
|
|
20
|
+
'enabledPlugins', // User-enabled/disabled plugins
|
|
21
|
+
'model', // User's preferred model
|
|
22
|
+
'skillSuggestions.enabled' // User preferences
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get a nested value from an object using dot notation path
|
|
27
|
+
*/
|
|
28
|
+
function getNestedValue(obj, path) {
|
|
29
|
+
return path.split('.').reduce((current, key) => {
|
|
30
|
+
return current && current[key] !== undefined ? current[key] : undefined;
|
|
31
|
+
}, obj);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Set a nested value in an object using dot notation path
|
|
36
|
+
*/
|
|
37
|
+
function setNestedValue(obj, path, value) {
|
|
38
|
+
const keys = path.split('.');
|
|
39
|
+
let current = obj;
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
42
|
+
const key = keys[i];
|
|
43
|
+
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
|
|
44
|
+
current[key] = {};
|
|
45
|
+
}
|
|
46
|
+
current = current[key];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
current[keys[keys.length - 1]] = value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if a key path is exactly protected or a parent of a protected key
|
|
54
|
+
*/
|
|
55
|
+
function isProtectedPath(keyPath) {
|
|
56
|
+
return PROTECTED_KEYS.some(protectedPath =>
|
|
57
|
+
keyPath === protectedPath || protectedPath.startsWith(keyPath + '.')
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if a key path is a protected key or a child of a protected key
|
|
63
|
+
*/
|
|
64
|
+
function isValueProtected(keyPath) {
|
|
65
|
+
return PROTECTED_KEYS.some(protectedPath =>
|
|
66
|
+
keyPath === protectedPath || keyPath.startsWith(protectedPath + '.')
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Deep merge two objects, preserving protected values from the original
|
|
72
|
+
*/
|
|
73
|
+
export function deepMergeWithProtection(original, updates, currentPath = '') {
|
|
74
|
+
const result = { ...original };
|
|
75
|
+
|
|
76
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
77
|
+
const keyPath = currentPath ? `${currentPath}.${key}` : key;
|
|
78
|
+
|
|
79
|
+
// If this specific value is protected and exists locally, skip it
|
|
80
|
+
if (isValueProtected(keyPath) && original.hasOwnProperty(key)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Special handling for mcpServers: merge individual server entries
|
|
85
|
+
if (key === 'mcpServers' && typeof value === 'object' && value !== null &&
|
|
86
|
+
typeof original[key] === 'object' && original[key] !== null) {
|
|
87
|
+
|
|
88
|
+
result[key] = { ...original[key] }; // Start with original servers
|
|
89
|
+
|
|
90
|
+
// Add servers from updates that don't exist in original
|
|
91
|
+
for (const [serverName, serverConfig] of Object.entries(value)) {
|
|
92
|
+
if (!result[key].hasOwnProperty(serverName)) {
|
|
93
|
+
result[key][serverName] = serverConfig;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else if (
|
|
97
|
+
typeof value === 'object' &&
|
|
98
|
+
value !== null &&
|
|
99
|
+
!Array.isArray(value) &&
|
|
100
|
+
typeof original[key] === 'object' &&
|
|
101
|
+
original[key] !== null &&
|
|
102
|
+
!Array.isArray(original[key])
|
|
103
|
+
) {
|
|
104
|
+
// Recursively merge nested objects
|
|
105
|
+
result[key] = deepMergeWithProtection(original[key], value, keyPath);
|
|
106
|
+
} else {
|
|
107
|
+
// Overwrite with new value for non-protected keys
|
|
108
|
+
result[key] = value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Atomically write data to a file using a temporary file
|
|
117
|
+
*/
|
|
118
|
+
export async function atomicWrite(filePath, data, options = {}) {
|
|
119
|
+
const {
|
|
120
|
+
preserveComments = false,
|
|
121
|
+
backupOnSuccess = false,
|
|
122
|
+
backupSuffix = '.bak'
|
|
123
|
+
} = options;
|
|
124
|
+
|
|
125
|
+
const tempFilePath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substr(2, 9)}`;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
let content;
|
|
129
|
+
if (preserveComments) {
|
|
130
|
+
content = stringify(data, null, 2);
|
|
131
|
+
} else {
|
|
132
|
+
content = JSON.stringify(data, null, 2);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await fs.writeFile(tempFilePath, content, 'utf8');
|
|
136
|
+
|
|
137
|
+
const tempStats = await fs.stat(tempFilePath);
|
|
138
|
+
if (tempStats.size === 0) {
|
|
139
|
+
throw new Error('Temporary file is empty - write failed');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (backupOnSuccess && await fs.pathExists(filePath)) {
|
|
143
|
+
const backupPath = `${filePath}${backupSuffix}`;
|
|
144
|
+
await fs.copy(filePath, backupPath);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await fs.rename(tempFilePath, filePath);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
try {
|
|
150
|
+
if (await fs.pathExists(tempFilePath)) {
|
|
151
|
+
await fs.unlink(tempFilePath);
|
|
152
|
+
}
|
|
153
|
+
} catch (cleanupError) {}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Safely read a JSON configuration file with error handling
|
|
160
|
+
*/
|
|
161
|
+
export async function safeReadConfig(filePath) {
|
|
162
|
+
try {
|
|
163
|
+
if (!(await fs.pathExists(filePath))) {
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
return parse(content);
|
|
171
|
+
} catch (parseError) {
|
|
172
|
+
return JSON.parse(content);
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error.code === 'ENOENT') return {};
|
|
176
|
+
throw new Error(`Failed to read config file: ${error.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Perform a safe merge of repository config with local config
|
|
182
|
+
*/
|
|
183
|
+
export async function safeMergeConfig(localConfigPath, repoConfig, options = {}) {
|
|
184
|
+
const {
|
|
185
|
+
preserveComments = true,
|
|
186
|
+
backupOnSuccess = true,
|
|
187
|
+
dryRun = false,
|
|
188
|
+
resolvedLocalConfig = null // NEW: pre-resolved local config with corrected paths
|
|
189
|
+
} = options;
|
|
190
|
+
|
|
191
|
+
// Use pre-resolved config if provided (fixes hardcoded paths), otherwise read from disk
|
|
192
|
+
const localConfig = resolvedLocalConfig || await safeReadConfig(localConfigPath);
|
|
193
|
+
const changes = [];
|
|
194
|
+
|
|
195
|
+
// Report MCP Servers preservation
|
|
196
|
+
if (localConfig.mcpServers && typeof localConfig.mcpServers === 'object') {
|
|
197
|
+
const localServerNames = Object.keys(localConfig.mcpServers);
|
|
198
|
+
if (localServerNames.length > 0) {
|
|
199
|
+
changes.push(`Preserved ${localServerNames.length} local mcpServers: ${localServerNames.join(', ')}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Report new MCP Servers additions
|
|
204
|
+
if (repoConfig.mcpServers && typeof repoConfig.mcpServers === 'object') {
|
|
205
|
+
const repoServerNames = Object.keys(repoConfig.mcpServers);
|
|
206
|
+
const newServerNames = repoServerNames.filter(name =>
|
|
207
|
+
!localConfig.mcpServers || !localConfig.mcpServers.hasOwnProperty(name)
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (newServerNames.length > 0) {
|
|
211
|
+
changes.push(`Added ${newServerNames.length} new non-conflicting mcpServers from repository: ${newServerNames.join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Perform the merge
|
|
216
|
+
let mergedConfig = deepMergeWithProtection(localConfig, repoConfig);
|
|
217
|
+
|
|
218
|
+
// Check if there are any differences
|
|
219
|
+
const configsAreEqual = JSON.stringify(localConfig) === JSON.stringify(mergedConfig);
|
|
220
|
+
|
|
221
|
+
if (!configsAreEqual && !dryRun) {
|
|
222
|
+
await atomicWrite(localConfigPath, mergedConfig, {
|
|
223
|
+
preserveComments,
|
|
224
|
+
backupOnSuccess
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
updated: !configsAreEqual,
|
|
230
|
+
changes: changes
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getProtectedKeys() {
|
|
235
|
+
return [...PROTECTED_KEYS];
|
|
236
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
|
|
4
|
+
export class EnvVarTransformer {
|
|
5
|
+
static transform(value, from, to) {
|
|
6
|
+
if (from === to) return value;
|
|
7
|
+
if (typeof value === "string") return this.transformString(value, from, to);
|
|
8
|
+
if (Array.isArray(value)) return value.map((item) => this.transform(item, from, to));
|
|
9
|
+
if (value && typeof value === "object") {
|
|
10
|
+
const result = {};
|
|
11
|
+
for (const [key, item] of Object.entries(value)) {
|
|
12
|
+
result[key] = this.transform(item, from, to);
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static transformString(value, from, to) {
|
|
20
|
+
const normalized = this.toNormalized(value, from);
|
|
21
|
+
return this.fromNormalized(normalized, to);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static toNormalized(value, from) {
|
|
25
|
+
switch (from) {
|
|
26
|
+
case "claude": return value;
|
|
27
|
+
case "cursor": return value.replace(/\$\{env:([A-Za-z0-9_]+)\}/g, "${$1}");
|
|
28
|
+
case "opencode": return value.replace(/\{env:([A-Za-z0-9_]+)\}/g, "${$1}");
|
|
29
|
+
case "gemini": return value; // Gemini uses ${VAR} like Claude
|
|
30
|
+
case "qwen": return value; // Qwen uses ${VAR} like Claude
|
|
31
|
+
default: return value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static fromNormalized(value, to) {
|
|
36
|
+
switch (to) {
|
|
37
|
+
case "claude": return value;
|
|
38
|
+
case "cursor":
|
|
39
|
+
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (match, name) => {
|
|
40
|
+
if (["workspaceFolder", "userHome"].includes(name)) return match;
|
|
41
|
+
return `\${env:${name}}`;
|
|
42
|
+
});
|
|
43
|
+
case "opencode":
|
|
44
|
+
return value.replace(/\$\{([A-Z0-9_]+)\}/g, "{env:$1}");
|
|
45
|
+
case "gemini": return value; // Gemini uses ${VAR} like Claude
|
|
46
|
+
case "qwen": return value; // Qwen uses ${VAR} like Claude
|
|
47
|
+
default: return value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ConfigAdapter {
|
|
53
|
+
constructor(systemRoot) {
|
|
54
|
+
this.systemRoot = systemRoot;
|
|
55
|
+
this.homeDir = os.homedir();
|
|
56
|
+
this.isClaude = systemRoot.includes('.claude') || systemRoot.includes('Claude');
|
|
57
|
+
this.isGemini = systemRoot.includes('.gemini') || systemRoot.includes('Gemini');
|
|
58
|
+
this.isQwen = systemRoot.includes('.qwen') || systemRoot.includes('Qwen');
|
|
59
|
+
this.isCursor = systemRoot.toLowerCase().includes('cursor');
|
|
60
|
+
this.isAntigravity = systemRoot.includes('antigravity');
|
|
61
|
+
|
|
62
|
+
this.targetFormat = this.isCursor ? 'cursor' :
|
|
63
|
+
this.isAntigravity ? 'antigravity' :
|
|
64
|
+
(this.isClaude ? 'claude' : 'claude');
|
|
65
|
+
this.hooksDir = path.join(this.systemRoot, 'hooks');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
adaptMcpConfig(canonicalConfig) {
|
|
69
|
+
if (!canonicalConfig || !canonicalConfig.mcpServers) return {};
|
|
70
|
+
const config = JSON.parse(JSON.stringify(canonicalConfig));
|
|
71
|
+
|
|
72
|
+
// Transform Env Vars
|
|
73
|
+
config.mcpServers = EnvVarTransformer.transform(config.mcpServers, 'claude', this.targetFormat);
|
|
74
|
+
|
|
75
|
+
// Apply format-specific transformations
|
|
76
|
+
if (this.isGemini || this.isQwen) {
|
|
77
|
+
this.transformToGeminiFormat(config.mcpServers);
|
|
78
|
+
} else if (this.isAntigravity) {
|
|
79
|
+
this.transformToAntigravityFormat(config.mcpServers);
|
|
80
|
+
} else if (this.isClaude) {
|
|
81
|
+
this.transformToClaudeFormat(config.mcpServers);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Resolve Paths
|
|
85
|
+
this.resolveMcpPaths(config.mcpServers);
|
|
86
|
+
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
adaptHooksConfig(canonicalHooks) {
|
|
91
|
+
if (!canonicalHooks) return {};
|
|
92
|
+
if (this.isCursor) return { hooks: {} };
|
|
93
|
+
|
|
94
|
+
const hooksConfig = JSON.parse(JSON.stringify(canonicalHooks));
|
|
95
|
+
|
|
96
|
+
if (this.isGemini) {
|
|
97
|
+
return this.transformToGeminiHooks(hooksConfig);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.resolveHookScripts(hooksConfig);
|
|
101
|
+
return hooksConfig;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
resolveMcpPaths(servers) {
|
|
105
|
+
for (const server of Object.values(servers)) {
|
|
106
|
+
if (server.args) server.args = server.args.map(arg => this.resolvePath(arg));
|
|
107
|
+
if (server.cwd) server.cwd = this.resolvePath(server.cwd);
|
|
108
|
+
if (server.env) {
|
|
109
|
+
for (const key in server.env) server.env[key] = this.resolvePath(server.env[key]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Transform canonical MCP config to Gemini/Qwen format
|
|
116
|
+
* - Remove 'type' field (not used)
|
|
117
|
+
* - Keep 'url' for HTTP servers
|
|
118
|
+
* - Keep 'command' and 'args' for stdio servers
|
|
119
|
+
*/
|
|
120
|
+
transformToGeminiFormat(servers) {
|
|
121
|
+
for (const server of Object.values(servers)) {
|
|
122
|
+
// Gemini doesn't use the 'type' field
|
|
123
|
+
delete server.type;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Transform canonical MCP config to Claude Code format
|
|
129
|
+
* - Ensure 'type' field is present (stdio/http/sse)
|
|
130
|
+
* - Keep 'url' for HTTP/SSE servers
|
|
131
|
+
*/
|
|
132
|
+
transformToClaudeFormat(servers) {
|
|
133
|
+
for (const server of Object.values(servers)) {
|
|
134
|
+
// Claude requires 'type' field for non-stdio servers
|
|
135
|
+
if (server.url && !server.type) {
|
|
136
|
+
// Determine type from URL pattern
|
|
137
|
+
if (server.url.includes('/sse')) {
|
|
138
|
+
server.type = 'sse';
|
|
139
|
+
} else {
|
|
140
|
+
server.type = 'http';
|
|
141
|
+
}
|
|
142
|
+
} else if (server.command && !server.type) {
|
|
143
|
+
server.type = 'stdio';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Transform canonical MCP config to Antigravity format
|
|
150
|
+
* - Ensure 'type' field is present
|
|
151
|
+
* - Transform 'url' to 'serverUrl' for HTTP/SSE servers
|
|
152
|
+
* - Support 'disabled' flag
|
|
153
|
+
*/
|
|
154
|
+
transformToAntigravityFormat(servers) {
|
|
155
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
156
|
+
// Ensure type is set
|
|
157
|
+
if (server.url && !server.type) {
|
|
158
|
+
if (server.url.includes('/sse')) {
|
|
159
|
+
server.type = 'sse';
|
|
160
|
+
} else {
|
|
161
|
+
server.type = 'http';
|
|
162
|
+
}
|
|
163
|
+
} else if (server.command && !server.type) {
|
|
164
|
+
server.type = 'stdio';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Transform url → serverUrl for HTTP/SSE servers
|
|
168
|
+
if (server.url && (server.type === 'http' || server.type === 'sse')) {
|
|
169
|
+
server.serverUrl = server.url;
|
|
170
|
+
delete server.url;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
resolveHookScripts(hooksConfig) {
|
|
176
|
+
if (hooksConfig.hooks) {
|
|
177
|
+
for (const [event, hooks] of Object.entries(hooksConfig.hooks)) {
|
|
178
|
+
if (Array.isArray(hooks)) {
|
|
179
|
+
hooks.forEach(hook => {
|
|
180
|
+
if (hook.script) {
|
|
181
|
+
hook.type = "command";
|
|
182
|
+
hook.command = path.join(this.hooksDir, hook.script);
|
|
183
|
+
delete hook.script;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (hooksConfig.statusLine && hooksConfig.statusLine.script) {
|
|
190
|
+
hooksConfig.statusLine.type = "command";
|
|
191
|
+
hooksConfig.statusLine.command = path.join(this.hooksDir, hooksConfig.statusLine.script);
|
|
192
|
+
delete hooksConfig.statusLine.script;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
transformToGeminiHooks(hooksConfig) {
|
|
197
|
+
const geminiHooks = { hooks: {} };
|
|
198
|
+
const eventMap = {
|
|
199
|
+
'UserPromptSubmit': 'BeforeAgent',
|
|
200
|
+
'PreToolUse': 'BeforeTool',
|
|
201
|
+
'SessionStart': 'SessionStart'
|
|
202
|
+
};
|
|
203
|
+
const toolMap = { 'Read': 'read_file', 'Write': 'write_file', 'Edit': 'replace', 'Bash': 'run_shell_command' };
|
|
204
|
+
|
|
205
|
+
for (const [event, hooks] of Object.entries(hooksConfig.hooks)) {
|
|
206
|
+
const geminiEvent = eventMap[event];
|
|
207
|
+
if (!geminiEvent) continue;
|
|
208
|
+
geminiHooks.hooks[geminiEvent] = hooks.map(hook => {
|
|
209
|
+
const newHook = { ...hook };
|
|
210
|
+
if (newHook.matcher) {
|
|
211
|
+
for (const [claudeTool, geminiTool] of Object.entries(toolMap)) {
|
|
212
|
+
newHook.matcher = newHook.matcher.replace(new RegExp(`\\b${claudeTool}\\b`, 'g'), geminiTool);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (newHook.script) {
|
|
216
|
+
newHook.type = "command";
|
|
217
|
+
newHook.command = path.join(this.hooksDir, newHook.script);
|
|
218
|
+
delete newHook.script;
|
|
219
|
+
}
|
|
220
|
+
newHook.timeout = newHook.timeout || 60000;
|
|
221
|
+
return newHook;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return geminiHooks;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
resolvePath(p) {
|
|
228
|
+
if (!p || typeof p !== 'string') return p;
|
|
229
|
+
return p.replace(/~\//g, this.homeDir + '/').replace(/\${HOME}/g, this.homeDir);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { parse, assign, stringify } from 'comment-json';
|
|
4
|
+
import kleur from 'kleur';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Safely inject hook configuration into settings.json
|
|
8
|
+
*/
|
|
9
|
+
export async function injectHookConfig(targetDir, repoRoot) {
|
|
10
|
+
const settingsPath = path.join(targetDir, 'settings.json');
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(settingsPath)) {
|
|
13
|
+
console.log(kleur.yellow(` [!] settings.json not found in ${targetDir}. Skipping auto-config.`));
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const rawContent = await fs.readFile(settingsPath, 'utf8');
|
|
19
|
+
const settings = parse(rawContent);
|
|
20
|
+
|
|
21
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
22
|
+
console.log(kleur.yellow(` [!] settings.json is not a valid object. Skipping auto-config.`));
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Define our required hooks
|
|
27
|
+
const requiredHooks = [
|
|
28
|
+
{
|
|
29
|
+
name: "skill-suggestion",
|
|
30
|
+
path: path.join(targetDir, 'hooks', 'skill-suggestion.sh'),
|
|
31
|
+
events: ["userPromptSubmit"]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "serena-workflow-reminder",
|
|
35
|
+
path: path.join(targetDir, 'hooks', 'serena-workflow-reminder.sh'),
|
|
36
|
+
events: ["toolUse"]
|
|
37
|
+
}
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
let modified = false;
|
|
41
|
+
|
|
42
|
+
if (!Array.isArray(settings.hooks)) {
|
|
43
|
+
settings.hooks = [];
|
|
44
|
+
modified = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const req of requiredHooks) {
|
|
48
|
+
const exists = settings.hooks.find(h => h.name === req.name || h.path === req.path);
|
|
49
|
+
|
|
50
|
+
if (!exists) {
|
|
51
|
+
console.log(kleur.blue(` [+] Adding hook: ${req.name}`));
|
|
52
|
+
settings.hooks.push(req);
|
|
53
|
+
modified = true;
|
|
54
|
+
} else {
|
|
55
|
+
// Optional: Update path if it changed
|
|
56
|
+
if (exists.path !== req.path) {
|
|
57
|
+
console.log(kleur.blue(` [^] Updating hook path: ${req.name}`));
|
|
58
|
+
exists.path = req.path;
|
|
59
|
+
modified = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (modified) {
|
|
65
|
+
// Backup
|
|
66
|
+
const backupPath = `${settingsPath}.bak`;
|
|
67
|
+
await fs.copy(settingsPath, backupPath);
|
|
68
|
+
console.log(kleur.gray(` [i] Backup created at settings.json.bak`));
|
|
69
|
+
|
|
70
|
+
// Write back with comments preserved
|
|
71
|
+
await fs.writeFile(settingsPath, stringify(settings, null, 2));
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return false;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(kleur.red(` [!] Error parsing settings.json: ${err.message}`));
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import Conf from 'conf';
|
|
5
|
+
import prompts from 'prompts';
|
|
6
|
+
import kleur from 'kleur';
|
|
7
|
+
|
|
8
|
+
// Initialize configuration (persists sync mode preference only)
|
|
9
|
+
const config = new Conf({
|
|
10
|
+
projectName: 'jaggers-config-manager',
|
|
11
|
+
defaults: {
|
|
12
|
+
syncMode: 'copy' // 'copy' or 'symlink'
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Define known paths including user requested ones
|
|
17
|
+
const CANDIDATE_PATHS = [
|
|
18
|
+
{ label: '.claude', path: path.join(os.homedir(), '.claude') },
|
|
19
|
+
{ label: '.gemini', path: path.join(os.homedir(), '.gemini') },
|
|
20
|
+
{ label: '.qwen', path: path.join(os.homedir(), '.qwen') },
|
|
21
|
+
{ label: '~/.gemini/antigravity', path: path.join(os.homedir(), '.gemini', 'antigravity') },
|
|
22
|
+
// Standard XDG/Windows paths
|
|
23
|
+
process.env.APPDATA ? { label: 'AppData/Claude', path: path.join(process.env.APPDATA, 'Claude') } : null
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
|
|
26
|
+
export async function getContext() {
|
|
27
|
+
// 1. Identify Existing vs Missing Paths
|
|
28
|
+
const choices = [];
|
|
29
|
+
|
|
30
|
+
for (const c of CANDIDATE_PATHS) {
|
|
31
|
+
const exists = await fs.pathExists(c.path);
|
|
32
|
+
const icon = exists ? '[X]' : '[ ]';
|
|
33
|
+
const desc = exists ? 'Found' : 'Not found (will create)';
|
|
34
|
+
|
|
35
|
+
choices.push({
|
|
36
|
+
title: `${icon} ${c.label} (${c.path})`,
|
|
37
|
+
description: desc,
|
|
38
|
+
value: c.path,
|
|
39
|
+
selected: exists // Pre-select existing environments
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Prompt user with Multiselect
|
|
44
|
+
const response = await prompts({
|
|
45
|
+
type: 'multiselect',
|
|
46
|
+
name: 'targets',
|
|
47
|
+
message: 'Select target environment(s):',
|
|
48
|
+
choices: choices,
|
|
49
|
+
hint: '- Space to select. Return to submit',
|
|
50
|
+
instructions: false
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (!response.targets || response.targets.length === 0) {
|
|
54
|
+
console.log(kleur.gray('No targets selected. Exiting.'));
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 3. Ensure directories exist for selected targets
|
|
59
|
+
for (const target of response.targets) {
|
|
60
|
+
await fs.ensureDir(target);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
targets: response.targets, // Array of path strings
|
|
65
|
+
syncMode: config.get('syncMode'),
|
|
66
|
+
config
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resetContext() {
|
|
71
|
+
config.clear();
|
|
72
|
+
console.log(kleur.yellow('Configuration cleared.'));
|
|
73
|
+
}
|