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,52 @@
1
+ export interface Skill {
2
+ name: string;
3
+ description: string;
4
+ content: string;
5
+ }
6
+
7
+ export interface Command {
8
+ name: string;
9
+ description: string;
10
+ prompt: string;
11
+ }
12
+
13
+ export interface Hook {
14
+ name?: string;
15
+ type?: string;
16
+ command?: string;
17
+ script?: string;
18
+ timeout?: number;
19
+ events?: string[];
20
+ matcher?: string;
21
+ }
22
+
23
+ export interface MCPServer {
24
+ type?: 'stdio' | 'http' | 'sse';
25
+ command?: string;
26
+ args?: string[];
27
+ env?: Record<string, string>;
28
+ url?: string;
29
+ serverUrl?: string;
30
+ headers?: Record<string, string>;
31
+ disabled?: boolean;
32
+ }
33
+
34
+ export interface SyncOptions {
35
+ dryRun?: boolean;
36
+ yes?: boolean;
37
+ prune?: boolean;
38
+ }
39
+
40
+ export interface ManifestItem {
41
+ type: 'skill' | 'hook' | 'config' | 'command';
42
+ name: string;
43
+ hash: string;
44
+ lastSync: string;
45
+ source: string;
46
+ }
47
+
48
+ export interface Manifest {
49
+ version: string;
50
+ lastSync: string;
51
+ items: Record<string, ManifestItem>;
52
+ }
@@ -0,0 +1,222 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'fs-extra';
3
+ // @ts-ignore
4
+ import { parse, stringify } from 'comment-json';
5
+
6
+ /**
7
+ * Atomic Configuration Handler with Vault Pattern
8
+ * Ensures safe read/write operations with protection against corruption during crashes
9
+ */
10
+
11
+ // Protected keys that should never be overwritten if they exist locally
12
+ const PROTECTED_KEYS = [
13
+ 'permissions.allow', // User-defined permissions
14
+ 'hooks.UserPromptSubmit', // Claude hooks
15
+ 'hooks.SessionStart',
16
+ 'hooks.PreToolUse',
17
+ 'hooks.BeforeAgent', // Gemini hooks
18
+ 'hooks.BeforeTool', // Gemini hooks
19
+ 'security', // Auth secrets/OAuth data
20
+ 'general', // Personal preferences
21
+ 'enabledPlugins', // User-enabled/disabled plugins
22
+ 'model', // User's preferred model
23
+ 'skillSuggestions.enabled' // User preferences
24
+ ];
25
+
26
+ /**
27
+ * Check if a key path is exactly protected or a parent of a protected key
28
+ */
29
+ export function isProtectedPath(keyPath: string): boolean {
30
+ return PROTECTED_KEYS.some(protectedPath =>
31
+ keyPath === protectedPath || protectedPath.startsWith(keyPath + '.')
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Check if a key path is a protected key or a child of a protected key
37
+ */
38
+ export function isValueProtected(keyPath: string): boolean {
39
+ return PROTECTED_KEYS.some(protectedPath =>
40
+ keyPath === protectedPath || keyPath.startsWith(protectedPath + '.')
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Deep merge two objects, preserving protected values from the original
46
+ */
47
+ export function deepMergeWithProtection(original: any, updates: any, currentPath: string = ''): any {
48
+ const result = { ...original };
49
+
50
+ for (const [key, value] of Object.entries(updates)) {
51
+ const keyPath = currentPath ? `${currentPath}.${key}` : key;
52
+
53
+ // If this specific value is protected and exists locally, skip it
54
+ if (isValueProtected(keyPath) && original.hasOwnProperty(key)) {
55
+ continue;
56
+ }
57
+
58
+ // Special handling for mcpServers: merge individual server entries
59
+ if (key === 'mcpServers' && typeof value === 'object' && value !== null &&
60
+ typeof original[key] === 'object' && original[key] !== null) {
61
+
62
+ result[key] = { ...original[key] }; // Start with original servers
63
+
64
+ // Add servers from updates that don't exist in original
65
+ for (const [serverName, serverConfig] of Object.entries(value)) {
66
+ if (!result[key].hasOwnProperty(serverName)) {
67
+ result[key][serverName] = serverConfig;
68
+ }
69
+ }
70
+ } else if (
71
+ typeof value === 'object' &&
72
+ value !== null &&
73
+ !Array.isArray(value) &&
74
+ typeof original[key] === 'object' &&
75
+ original[key] !== null &&
76
+ !Array.isArray(original[key])
77
+ ) {
78
+ // Recursively merge nested objects
79
+ result[key] = deepMergeWithProtection(original[key], value, keyPath);
80
+ } else {
81
+ // Overwrite with new value for non-protected keys
82
+ result[key] = value;
83
+ }
84
+ }
85
+
86
+ return result;
87
+ }
88
+
89
+ interface AtomicWriteOptions {
90
+ preserveComments?: boolean;
91
+ backupOnSuccess?: boolean;
92
+ backupSuffix?: string;
93
+ }
94
+
95
+ /**
96
+ * Atomically write data to a file using a temporary file
97
+ */
98
+ export async function atomicWrite(filePath: string, data: any, options: AtomicWriteOptions = {}): Promise<void> {
99
+ const {
100
+ preserveComments = false,
101
+ backupOnSuccess = false,
102
+ backupSuffix = '.bak'
103
+ } = options;
104
+
105
+ const tempFilePath = `${filePath}.tmp.${randomUUID()}`;
106
+
107
+ try {
108
+ let content: string;
109
+ if (preserveComments) {
110
+ content = stringify(data, null, 2);
111
+ } else {
112
+ content = JSON.stringify(data, null, 2);
113
+ }
114
+
115
+ await fs.writeFile(tempFilePath, content, 'utf8');
116
+
117
+ const tempStats = await fs.stat(tempFilePath);
118
+ if (tempStats.size === 0) {
119
+ throw new Error('Temporary file is empty - write failed');
120
+ }
121
+
122
+ if (backupOnSuccess && await fs.pathExists(filePath)) {
123
+ const backupPath = `${filePath}${backupSuffix}`;
124
+ await fs.copy(filePath, backupPath);
125
+ }
126
+
127
+ await fs.rename(tempFilePath, filePath);
128
+ } catch (error) {
129
+ try {
130
+ if (await fs.pathExists(tempFilePath)) {
131
+ await fs.unlink(tempFilePath);
132
+ }
133
+ } catch (cleanupError) { }
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Safely read a JSON configuration file with error handling
140
+ */
141
+ export async function safeReadConfig(filePath: string): Promise<any> {
142
+ try {
143
+ if (!(await fs.pathExists(filePath))) {
144
+ return {};
145
+ }
146
+
147
+ const content = await fs.readFile(filePath, 'utf8');
148
+
149
+ try {
150
+ return parse(content);
151
+ } catch (parseError) {
152
+ return JSON.parse(content);
153
+ }
154
+ } catch (error: any) {
155
+ if (error.code === 'ENOENT') return {};
156
+ throw new Error(`Failed to read config file: ${error.message}`);
157
+ }
158
+ }
159
+
160
+ interface MergeOptions {
161
+ preserveComments?: boolean;
162
+ backupOnSuccess?: boolean;
163
+ dryRun?: boolean;
164
+ resolvedLocalConfig?: any;
165
+ }
166
+
167
+ export interface MergeResult {
168
+ updated: boolean;
169
+ changes: string[];
170
+ }
171
+
172
+ /**
173
+ * Perform a safe merge of repository config with local config
174
+ */
175
+ export async function safeMergeConfig(localConfigPath: string, repoConfig: any, options: MergeOptions = {}): Promise<MergeResult> {
176
+ const {
177
+ preserveComments = true,
178
+ backupOnSuccess = true,
179
+ dryRun = false,
180
+ resolvedLocalConfig = null
181
+ } = options;
182
+
183
+ const localConfig = resolvedLocalConfig || await safeReadConfig(localConfigPath);
184
+ const changes: string[] = [];
185
+
186
+ if (localConfig.mcpServers && typeof localConfig.mcpServers === 'object') {
187
+ const localServerNames = Object.keys(localConfig.mcpServers);
188
+ if (localServerNames.length > 0) {
189
+ changes.push(`Preserved ${localServerNames.length} local mcpServers: ${localServerNames.join(', ')}`);
190
+ }
191
+ }
192
+
193
+ if (repoConfig.mcpServers && typeof repoConfig.mcpServers === 'object') {
194
+ const repoServerNames = Object.keys(repoConfig.mcpServers);
195
+ const newServerNames = repoServerNames.filter(name =>
196
+ !localConfig.mcpServers || !localConfig.mcpServers.hasOwnProperty(name)
197
+ );
198
+
199
+ if (newServerNames.length > 0) {
200
+ changes.push(`Added ${newServerNames.length} new non-conflicting mcpServers from repository: ${newServerNames.join(', ')}`);
201
+ }
202
+ }
203
+
204
+ const mergedConfig = deepMergeWithProtection(localConfig, repoConfig);
205
+ const configsAreEqual = JSON.stringify(localConfig) === JSON.stringify(mergedConfig);
206
+
207
+ if (!configsAreEqual && !dryRun) {
208
+ await atomicWrite(localConfigPath, mergedConfig, {
209
+ preserveComments,
210
+ backupOnSuccess
211
+ });
212
+ }
213
+
214
+ return {
215
+ updated: !configsAreEqual,
216
+ changes
217
+ };
218
+ }
219
+
220
+ export function getProtectedKeys(): string[] {
221
+ return [...PROTECTED_KEYS];
222
+ }
@@ -0,0 +1,194 @@
1
+ import kleur from 'kleur';
2
+
3
+ // ── ASCII art ───────────────────────────────────────────────────────────────
4
+ const ART = [
5
+ ' ██╗ ██╗████████╗██████╗ ███╗ ███╗ ████████╗ ██████╗ ██████╗ ██╗ ███████╗',
6
+ ' ╚██╗██╔╝╚══██╔══╝██╔══██╗████╗ ████║ ╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██╔════╝',
7
+ ' ╚███╔╝ ██║ ██████╔╝██╔████╔██║ ██║ ██║ ██║██║ ██║██║ ███████╗',
8
+ ' ██╔██╗ ██║ ██╔══██╗██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██║ ╚════██║',
9
+ ' ██╔╝ ██╗ ██║ ██║ ██║██║ ╚═╝ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗███████║',
10
+ ' ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝',
11
+ ];
12
+
13
+ const ART_WIDTH = 84;
14
+
15
+ // ── Detection helpers ───────────────────────────────────────────────────────
16
+
17
+ function isTTY(): boolean { return Boolean(process.stdout.isTTY); }
18
+
19
+ function hasColor(): boolean {
20
+ if (process.env.NO_COLOR !== undefined) return false;
21
+ if (process.env.FORCE_COLOR !== undefined) return true;
22
+ if (process.env.TERM === 'dumb') return false;
23
+ return isTTY();
24
+ }
25
+
26
+ function isCI(): boolean { return Boolean(process.env.CI); }
27
+ function isDumb(): boolean { return process.env.TERM === 'dumb'; }
28
+ function columns(): number { return process.stdout.columns ?? 80; }
29
+
30
+ function hasUnicode(): boolean {
31
+ if (process.platform === 'win32') {
32
+ return Boolean(process.env.WT_SESSION || process.env.TERM_PROGRAM);
33
+ }
34
+ const lang = (process.env.LANG ?? process.env.LC_ALL ?? process.env.LC_CTYPE ?? '').toUpperCase();
35
+ return lang.includes('UTF') || lang === '';
36
+ }
37
+
38
+ function hasTruecolor(): boolean {
39
+ const ct = (process.env.COLORTERM ?? '').toLowerCase();
40
+ return ct === 'truecolor' || ct === '24bit';
41
+ }
42
+
43
+ // ── Tier selection ──────────────────────────────────────────────────────────
44
+
45
+ type Tier = 0 | 1 | 2 | 3 | 4;
46
+
47
+ function selectTier(): Tier {
48
+ if (!isTTY() || isDumb() || isCI()) return 4;
49
+ if (!hasColor()) return 4;
50
+ if (!hasUnicode()) return 3;
51
+ const cols = columns();
52
+ if (cols < 40) return 4;
53
+ if (cols < 85) return 2;
54
+ if (hasTruecolor()) return 0;
55
+ return 1;
56
+ }
57
+
58
+ // ── Press-any-key with passive 1s auto-timeout ─────────────────────────────
59
+
60
+ function pressAnyKey(): Promise<void> {
61
+ return new Promise(resolve => {
62
+ process.stdout.write(kleur.dim('\n press any key to continue...'));
63
+ process.stdin.setRawMode(true);
64
+ process.stdin.resume();
65
+
66
+ let done = false;
67
+ const finish = (): void => {
68
+ if (done) return;
69
+ done = true;
70
+ process.stdin.setRawMode(false);
71
+ process.stdin.pause();
72
+ process.stdout.write('\r\x1b[2K');
73
+ resolve();
74
+ };
75
+
76
+ process.stdin.once('data', finish);
77
+ setTimeout(finish, 4000);
78
+ });
79
+ }
80
+
81
+ // ── Typewriter tagline (bold-white, 42ms per char) ─────────────────────────
82
+
83
+ function delay(ms: number): Promise<void> {
84
+ return new Promise(r => setTimeout(r, ms));
85
+ }
86
+
87
+ async function typewriterTagline(text: string): Promise<void> {
88
+ const CHAR_DELAY = 42; // ms per character
89
+ const prefix = ` \x1b[2m\u2014\x1b[0m \x1b[1m\x1b[37m`;
90
+ const suffix = `\x1b[0m \x1b[2m\u2014\x1b[0m`;
91
+
92
+ process.stdout.write(prefix);
93
+ for (const ch of text) {
94
+ process.stdout.write(ch);
95
+ await delay(CHAR_DELAY);
96
+ }
97
+ process.stdout.write(suffix + '\n');
98
+ }
99
+
100
+ // ── Truecolor gradient (item 7: 170°→230° teal→indigo, sat=0.6) ───────────
101
+
102
+ const HUE_START = 170;
103
+ const HUE_END = 230;
104
+ const SAT = 0.6;
105
+ const LIG = 0.72;
106
+
107
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
108
+ const a = s * Math.min(l, 1 - l);
109
+ const f = (n: number): number => {
110
+ const k = (n + h / 30) % 12;
111
+ return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
112
+ };
113
+ return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
114
+ }
115
+
116
+ const RESET = '\x1b[0m';
117
+
118
+ function gradientLine(line: string): string {
119
+ let out = '';
120
+ const len = line.length;
121
+ for (let i = 0; i < len; i++) {
122
+ const t = len > 1 ? i / (len - 1) : 0;
123
+ const hue = HUE_START + t * (HUE_END - HUE_START);
124
+ const [r, g, b] = hslToRgb(hue, SAT, LIG);
125
+ out += `\x1b[38;2;${r};${g};${b}m` + line[i];
126
+ }
127
+ return out + RESET;
128
+ }
129
+
130
+ // ── Renderers ───────────────────────────────────────────────────────────────
131
+
132
+ async function renderTier0(version: string): Promise<void> {
133
+ const rule = '\x1b[2m ' + '─'.repeat(ART_WIDTH - 1) + '\x1b[0m';
134
+
135
+ process.stdout.write('\n');
136
+ for (const line of ART) {
137
+ process.stdout.write(gradientLine(line) + '\n');
138
+ }
139
+ process.stdout.write(rule + '\n');
140
+ process.stdout.write(' \x1b[2mv' + version + '\x1b[0m\n');
141
+ await typewriterTagline('Sync agent tools across AI environments');
142
+ }
143
+
144
+ function renderTier1(version: string): void {
145
+ console.log('');
146
+ for (const line of ART) { console.log(kleur.cyan(line)); }
147
+ console.log(kleur.dim(' ' + '─'.repeat(ART_WIDTH - 1)));
148
+ console.log(kleur.dim(' v' + version));
149
+ console.log(
150
+ ' ' + kleur.dim('\u2014') + ' ' +
151
+ kleur.bold().white('Sync agent tools across AI environments') +
152
+ ' ' + kleur.dim('\u2014'),
153
+ );
154
+ console.log('');
155
+ }
156
+
157
+ function renderTier2(version: string): void {
158
+ console.log('');
159
+ console.log(
160
+ kleur.cyan(' ◈ ') +
161
+ kleur.bold().white('xtrm-tools') +
162
+ kleur.dim(' v' + version),
163
+ );
164
+ console.log(kleur.dim(' Sync agent tools across AI environments'));
165
+ console.log('');
166
+ }
167
+
168
+ function renderTier3(version: string): void {
169
+ console.log('');
170
+ console.log(kleur.bold(' xtrm-tools') + kleur.dim(' v' + version));
171
+ console.log(kleur.dim(' Sync agent tools across AI environments'));
172
+ console.log('');
173
+ }
174
+
175
+ function renderTier4(version: string): void {
176
+ console.log('');
177
+ console.log('xtrm-tools v' + version);
178
+ console.log('Sync agent tools across AI environments');
179
+ console.log('');
180
+ }
181
+
182
+ // ── Public API ──────────────────────────────────────────────────────────────
183
+
184
+ export async function printBanner(version: string): Promise<void> {
185
+ const tier = selectTier();
186
+ switch (tier) {
187
+ case 0: await renderTier0(version); break;
188
+ case 1: renderTier1(version); break;
189
+ case 2: renderTier2(version); break;
190
+ case 3: renderTier3(version); break;
191
+ case 4: renderTier4(version); return;
192
+ }
193
+ if (isTTY()) await pressAnyKey();
194
+ }
@@ -0,0 +1,90 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+
4
+ /**
5
+ * ConfigAdapter for Claude Code only.
6
+ *
7
+ * ARCHITECTURAL DECISION (v2.0.0): xtrm-tools now supports Claude Code exclusively.
8
+ * Hook translation for Gemini/Qwen was removed due to fragile, undocumented ecosystems.
9
+ * See PROJECT-SKILLS-ARCHITECTURE.md Section 3.1 for details.
10
+ */
11
+ export class ConfigAdapter {
12
+ systemRoot: string;
13
+ homeDir: string;
14
+ hooksDir: string;
15
+
16
+ constructor(systemRoot: string) {
17
+ this.systemRoot = systemRoot;
18
+ this.homeDir = os.homedir();
19
+ this.hooksDir = path.join(this.systemRoot, 'hooks');
20
+ }
21
+
22
+ /**
23
+ * Adapt hooks config for Claude Code format.
24
+ * Transforms flat hook definitions into Claude's wrapped format.
25
+ */
26
+ adaptHooksConfig(canonicalHooks: any): any {
27
+ if (!canonicalHooks) return {};
28
+
29
+ const hooksConfig = JSON.parse(JSON.stringify(canonicalHooks));
30
+ this.resolveHookScripts(hooksConfig);
31
+ return hooksConfig;
32
+ }
33
+
34
+ /**
35
+ * Resolve hook script paths and transform into Claude's command format.
36
+ * Converts { script: "foo.py" } → { type: "command", command: "python3 /full/path/foo.py" }
37
+ */
38
+ resolveHookScripts(hooksConfig: any): void {
39
+ if (hooksConfig.hooks) {
40
+ for (const [event, hooks] of Object.entries(hooksConfig.hooks)) {
41
+ if (Array.isArray(hooks)) {
42
+ // Transform flat hooks into Claude's wrapped format:
43
+ // { matcher?, hooks: [{ type, command, timeout }] }
44
+ hooksConfig.hooks[event] = hooks.map((hook: any) => {
45
+ if (hook.script) {
46
+ const resolvedScriptPath = this.resolvePath(path.join(this.hooksDir, hook.script));
47
+ const command = this.buildScriptCommand(hook.script, resolvedScriptPath);
48
+ const innerHook: any = { type: "command", command };
49
+ if (hook.timeout) innerHook.timeout = hook.timeout;
50
+
51
+ const wrapper: any = { hooks: [innerHook] };
52
+ if (hook.matcher) wrapper.matcher = hook.matcher;
53
+ return wrapper;
54
+ }
55
+ return hook;
56
+ });
57
+ }
58
+ }
59
+ }
60
+ if (hooksConfig.statusLine && hooksConfig.statusLine.script) {
61
+ const resolvedScriptPath = this.resolvePath(path.join(this.hooksDir, hooksConfig.statusLine.script));
62
+ const command = this.buildScriptCommand(hooksConfig.statusLine.script, resolvedScriptPath);
63
+ hooksConfig.statusLine = { type: "command", command };
64
+ }
65
+ }
66
+
67
+ buildScriptCommand(scriptName: string, resolvedPath: string): string {
68
+ const ext = path.extname(scriptName).toLowerCase();
69
+ if (ext === '.js' || ext === '.cjs' || ext === '.mjs') {
70
+ return `node "${resolvedPath}"`;
71
+ } else if (ext === '.sh') {
72
+ return `bash "${resolvedPath}"`;
73
+ } else {
74
+ const pythonBin = process.platform === 'win32' ? 'python' : 'python3';
75
+ return `${pythonBin} "${resolvedPath}"`;
76
+ }
77
+ }
78
+
79
+ resolvePath(p: string): string {
80
+ if (!p || typeof p !== 'string') return p;
81
+ let resolved = p.replace(/~\//g, this.homeDir + '/').replace(/\${HOME}/g, this.homeDir);
82
+
83
+ // Windows compatibility: use forward slashes in config files
84
+ if (process.platform === 'win32') {
85
+ resolved = resolved.replace(/\\/g, '/');
86
+ }
87
+
88
+ return resolved;
89
+ }
90
+ }
@@ -0,0 +1,81 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ // @ts-ignore
4
+ import { parse, stringify } from 'comment-json';
5
+ import kleur from 'kleur';
6
+
7
+ /**
8
+ * Safely inject hook configuration into settings.json
9
+ */
10
+ export async function injectHookConfig(targetDir: string, repoRoot: string): Promise<boolean> {
11
+ const settingsPath = path.join(targetDir, 'settings.json');
12
+
13
+ if (!(await fs.pathExists(settingsPath))) {
14
+ console.log(kleur.yellow(` [!] settings.json not found in ${targetDir}. Skipping auto-config.`));
15
+ return false;
16
+ }
17
+
18
+ try {
19
+ const rawContent = await fs.readFile(settingsPath, 'utf8');
20
+ const settings = parse(rawContent);
21
+
22
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
23
+ console.log(kleur.yellow(` [!] settings.json is not a valid object. Skipping auto-config.`));
24
+ return false;
25
+ }
26
+
27
+ // Define our required hooks
28
+ const requiredHooks = [
29
+ {
30
+ name: 'skill-suggestion',
31
+ path: path.join(targetDir, 'hooks', 'skill-suggestion.py'), // It's python!
32
+ events: ['userPromptSubmit'],
33
+ },
34
+ {
35
+ name: 'serena-workflow-reminder',
36
+ path: path.join(targetDir, 'hooks', 'serena-workflow-reminder.py'),
37
+ events: ['toolUse'],
38
+ },
39
+ ];
40
+
41
+ let modified = false;
42
+
43
+ if (!Array.isArray((settings as any).hooks)) {
44
+ (settings as any).hooks = [];
45
+ modified = true;
46
+ }
47
+
48
+ for (const req of requiredHooks) {
49
+ const exists = (settings as any).hooks.find((h: any) => h.name === req.name || h.path === req.path);
50
+
51
+ if (!exists) {
52
+ console.log(kleur.blue(` [+] Adding hook: ${req.name}`));
53
+ (settings as any).hooks.push(req);
54
+ modified = true;
55
+ } else {
56
+ // Optional: Update path if it changed
57
+ if (exists.path !== req.path) {
58
+ console.log(kleur.blue(` [^] Updating hook path: ${req.name}`));
59
+ exists.path = req.path;
60
+ modified = true;
61
+ }
62
+ }
63
+ }
64
+
65
+ if (modified) {
66
+ // Backup
67
+ const backupPath = `${settingsPath}.bak`;
68
+ await fs.copy(settingsPath, backupPath);
69
+ console.log(kleur.gray(` [i] Backup created at settings.json.bak`));
70
+
71
+ // Write back with comments preserved
72
+ await fs.writeFile(settingsPath, stringify(settings, null, 2));
73
+ return true;
74
+ }
75
+
76
+ return false;
77
+ } catch (err: any) {
78
+ console.error(kleur.red(` [!] Error parsing settings.json: ${err.message}`));
79
+ return false;
80
+ }
81
+ }