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.
Files changed (93) hide show
  1. package/.gemini/settings.json +39 -0
  2. package/dist/index.cjs +57378 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2 -0
  5. package/extensions/beads.ts +109 -0
  6. package/extensions/core/adapter.ts +45 -0
  7. package/extensions/core/lib.ts +3 -0
  8. package/extensions/core/logger.ts +45 -0
  9. package/extensions/core/runner.ts +71 -0
  10. package/extensions/custom-footer.ts +160 -0
  11. package/extensions/main-guard-post-push.ts +44 -0
  12. package/extensions/main-guard.ts +126 -0
  13. package/extensions/minimal-mode.ts +201 -0
  14. package/extensions/quality-gates.ts +67 -0
  15. package/extensions/service-skills.ts +150 -0
  16. package/extensions/xtrm-loader.ts +89 -0
  17. package/hooks/gitnexus-impact-reminder.py +13 -0
  18. package/lib/atomic-config.js +236 -0
  19. package/lib/config-adapter.js +231 -0
  20. package/lib/config-injector.js +80 -0
  21. package/lib/context.js +73 -0
  22. package/lib/diff.js +142 -0
  23. package/lib/env-manager.js +160 -0
  24. package/lib/sync-mcp-cli.js +345 -0
  25. package/lib/sync.js +227 -0
  26. package/package.json +47 -0
  27. package/src/adapters/base.ts +29 -0
  28. package/src/adapters/claude.ts +38 -0
  29. package/src/adapters/registry.ts +21 -0
  30. package/src/commands/claude.ts +122 -0
  31. package/src/commands/clean.ts +371 -0
  32. package/src/commands/end.ts +239 -0
  33. package/src/commands/finish.ts +25 -0
  34. package/src/commands/help.ts +180 -0
  35. package/src/commands/init.ts +959 -0
  36. package/src/commands/install-pi.ts +276 -0
  37. package/src/commands/install-service-skills.ts +281 -0
  38. package/src/commands/install.ts +427 -0
  39. package/src/commands/pi-install.ts +119 -0
  40. package/src/commands/pi.ts +128 -0
  41. package/src/commands/reset.ts +12 -0
  42. package/src/commands/status.ts +170 -0
  43. package/src/commands/worktree.ts +193 -0
  44. package/src/core/context.ts +141 -0
  45. package/src/core/diff.ts +174 -0
  46. package/src/core/interactive-plan.ts +165 -0
  47. package/src/core/manifest.ts +26 -0
  48. package/src/core/preflight.ts +142 -0
  49. package/src/core/rollback.ts +32 -0
  50. package/src/core/session-state.ts +139 -0
  51. package/src/core/sync-executor.ts +427 -0
  52. package/src/core/xtrm-finish.ts +267 -0
  53. package/src/index.ts +87 -0
  54. package/src/tests/policy-parity.test.ts +204 -0
  55. package/src/tests/session-flow-parity.test.ts +118 -0
  56. package/src/tests/session-state.test.ts +124 -0
  57. package/src/tests/xtrm-finish.test.ts +148 -0
  58. package/src/types/config.ts +51 -0
  59. package/src/types/models.ts +52 -0
  60. package/src/utils/atomic-config.ts +467 -0
  61. package/src/utils/banner.ts +194 -0
  62. package/src/utils/config-adapter.ts +90 -0
  63. package/src/utils/config-injector.ts +81 -0
  64. package/src/utils/env-manager.ts +193 -0
  65. package/src/utils/hash.ts +42 -0
  66. package/src/utils/repo-root.ts +39 -0
  67. package/src/utils/sync-mcp-cli.ts +395 -0
  68. package/src/utils/theme.ts +37 -0
  69. package/src/utils/worktree-session.ts +93 -0
  70. package/test/atomic-config-prune.test.ts +101 -0
  71. package/test/atomic-config.test.ts +138 -0
  72. package/test/clean.test.ts +172 -0
  73. package/test/config-schema.test.ts +52 -0
  74. package/test/context.test.ts +33 -0
  75. package/test/end-worktree.test.ts +168 -0
  76. package/test/extensions/beads.test.ts +166 -0
  77. package/test/extensions/extension-harness.ts +85 -0
  78. package/test/extensions/main-guard.test.ts +77 -0
  79. package/test/extensions/minimal-mode.test.ts +107 -0
  80. package/test/extensions/quality-gates.test.ts +79 -0
  81. package/test/extensions/service-skills.test.ts +84 -0
  82. package/test/extensions/xtrm-loader.test.ts +53 -0
  83. package/test/hooks/quality-check-hooks.test.ts +45 -0
  84. package/test/hooks.test.ts +1075 -0
  85. package/test/install-pi.test.ts +185 -0
  86. package/test/install-project.test.ts +378 -0
  87. package/test/install-service-skills.test.ts +131 -0
  88. package/test/install-surface.test.ts +72 -0
  89. package/test/runtime-subcommands.test.ts +121 -0
  90. package/test/session-launcher.test.ts +139 -0
  91. package/tsconfig.json +22 -0
  92. package/tsup.config.ts +17 -0
  93. 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
+ }