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,2 @@
1
+
2
+ export { }
package/index.js ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+
3
+ import kleur from 'kleur';
4
+ import minimist from 'minimist';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import prompts from 'prompts';
8
+ import { getContext, resetContext } from './lib/context.js';
9
+ import { calculateDiff } from './lib/diff.js';
10
+ import { executeSync } from './lib/sync.js';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const repoRoot = path.resolve(__dirname, '..');
14
+
15
+ const args = minimist(process.argv.slice(2));
16
+
17
+ function printDetails(title, items, colorFn) {
18
+ if (items.length > 0) {
19
+ console.log(colorFn(`\n ${title}:`));
20
+ items.forEach(item => console.log(colorFn(` - ${item}`)));
21
+ }
22
+ }
23
+
24
+ async function processTarget(targetDir, syncMode) {
25
+ console.log(kleur.cyan().bold(`\nTarget: ${targetDir}`));
26
+
27
+ const isClaude = targetDir.includes('.claude') || targetDir.includes('Claude');
28
+ const isDryRun = !!args['dry-run'];
29
+
30
+ if (isDryRun) {
31
+ console.log(kleur.yellow().bold(' [DRY RUN MODE - No changes will be written to disk]'));
32
+ }
33
+
34
+ // 1. Scan/Diff
35
+ console.log(kleur.gray('Scanning differences...'));
36
+ const changeSet = await calculateDiff(repoRoot, targetDir);
37
+
38
+ const categories = ['skills', 'hooks', 'config', 'commands'];
39
+
40
+ const totalMissing = categories.reduce((sum, cat) => sum + changeSet[cat].missing.length, 0);
41
+ const totalOutdated = categories.reduce((sum, cat) => sum + changeSet[cat].outdated.length, 0);
42
+ const totalDrifted = categories.reduce((sum, cat) => sum + changeSet[cat].drifted.length, 0);
43
+
44
+ // 2. Display Detailed Breakdown
45
+ if (totalMissing === 0 && totalOutdated === 0 && totalDrifted === 0) {
46
+ console.log(kleur.green('System is up to date.'));
47
+ } else {
48
+ console.log(kleur.bold('Analysis Results:'));
49
+
50
+ // Missing (Green)
51
+ const missingItems = [];
52
+ categories.forEach(cat => {
53
+ changeSet[cat].missing.forEach(item => {
54
+ let prefix = cat;
55
+ if (cat === 'commands') {
56
+ prefix = isClaude ? '.claude/commands' : '.gemini/commands';
57
+ }
58
+ missingItems.push(`${prefix}/${item}`);
59
+ });
60
+ });
61
+ printDetails('[+] Missing in System (Will be Installed)', missingItems, kleur.green);
62
+
63
+ // Outdated (Blue)
64
+ const outdatedItems = [];
65
+ categories.forEach(cat => {
66
+ changeSet[cat].outdated.forEach(item => {
67
+ let prefix = cat;
68
+ if (cat === 'commands') {
69
+ prefix = isClaude ? '.claude/commands' : '.gemini/commands';
70
+ }
71
+ outdatedItems.push(`${prefix}/${item}`);
72
+ });
73
+ });
74
+ printDetails('[^] Outdated in System (Will be Updated)', outdatedItems, kleur.blue);
75
+
76
+ // Drifted (Magenta)
77
+ const driftedItems = [];
78
+ categories.forEach(cat => {
79
+ changeSet[cat].drifted.forEach(item => {
80
+ let prefix = cat;
81
+ if (cat === 'commands') {
82
+ prefix = isClaude ? '.claude/commands' : '.gemini/commands';
83
+ }
84
+ driftedItems.push(`${prefix}/${item}`);
85
+ });
86
+ });
87
+ printDetails('[<] Drifted / Locally Modified (Needs Backport or Manual Merge)', driftedItems, kleur.magenta);
88
+
89
+ console.log(''); // spacer
90
+ }
91
+
92
+ // 3. Prompt for Action (Per Target)
93
+ const actions = [];
94
+ if (totalMissing > 0 || totalOutdated > 0) {
95
+ actions.push({ title: 'Sync Repo -> System (Update/Install)', value: 'sync' });
96
+ }
97
+ if (totalDrifted > 0) {
98
+ actions.push({ title: 'Backport System -> Repo (Save local changes)', value: 'backport' });
99
+ }
100
+
101
+ actions.push({ title: 'Skip this target', value: 'skip' });
102
+
103
+ const response = await prompts({
104
+ type: 'select',
105
+ name: 'action',
106
+ message: 'What would you like to do?',
107
+ choices: actions
108
+ });
109
+
110
+ if (!response.action || response.action === 'skip') {
111
+ console.log(kleur.gray('Skipping.'));
112
+ return;
113
+ }
114
+
115
+ // Execute Sync/Backport
116
+ console.log(kleur.gray('\nExecuting changes...'));
117
+ const count = await executeSync(repoRoot, targetDir, changeSet, syncMode, response.action, isDryRun);
118
+
119
+ console.log(kleur.green().bold(`\nSuccessfully processed ${count} items.`));
120
+ }
121
+
122
+ async function main() {
123
+ console.log(kleur.cyan().bold('\nJaggers Agent Tools - Config Manager'));
124
+
125
+ if (args.reset) {
126
+ resetContext();
127
+ }
128
+
129
+ try {
130
+ const context = await getContext();
131
+
132
+ console.log(kleur.dim(`\nMode: ${context.syncMode}`));
133
+ console.log(kleur.dim(`Selected Targets: ${context.targets.length}`));
134
+
135
+ for (const target of context.targets) {
136
+ await processTarget(target, context.syncMode);
137
+ }
138
+
139
+ console.log(kleur.gray('\nAll operations complete. Goodbye!'));
140
+
141
+ } catch (err) {
142
+ if (err.message === 'SIGINT') {
143
+ console.log(kleur.yellow('\nExited.'));
144
+ } else {
145
+ console.error(kleur.red(`\nError: ${err.message}`));
146
+ }
147
+ process.exit(1);
148
+ }
149
+ }
150
+
151
+ main();
@@ -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
+ }