coder-config 0.40.1

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/cli.js +431 -0
  4. package/config-loader.js +294 -0
  5. package/hooks/activity-track.sh +56 -0
  6. package/hooks/codex-workstream.sh +44 -0
  7. package/hooks/gemini-workstream.sh +44 -0
  8. package/hooks/workstream-inject.sh +20 -0
  9. package/lib/activity.js +283 -0
  10. package/lib/apply.js +344 -0
  11. package/lib/cli.js +267 -0
  12. package/lib/config.js +171 -0
  13. package/lib/constants.js +55 -0
  14. package/lib/env.js +114 -0
  15. package/lib/index.js +47 -0
  16. package/lib/init.js +122 -0
  17. package/lib/mcps.js +139 -0
  18. package/lib/memory.js +201 -0
  19. package/lib/projects.js +138 -0
  20. package/lib/registry.js +83 -0
  21. package/lib/utils.js +129 -0
  22. package/lib/workstreams.js +652 -0
  23. package/package.json +80 -0
  24. package/scripts/capture-screenshots.js +142 -0
  25. package/scripts/postinstall.js +122 -0
  26. package/scripts/release.sh +71 -0
  27. package/scripts/sync-version.js +77 -0
  28. package/scripts/tauri-prepare.js +328 -0
  29. package/shared/mcp-registry.json +76 -0
  30. package/ui/dist/assets/index-DbZ3_HBD.js +3204 -0
  31. package/ui/dist/assets/index-DjLdm3Mr.css +32 -0
  32. package/ui/dist/icons/icon-192.svg +16 -0
  33. package/ui/dist/icons/icon-512.svg +16 -0
  34. package/ui/dist/index.html +39 -0
  35. package/ui/dist/manifest.json +25 -0
  36. package/ui/dist/sw.js +24 -0
  37. package/ui/dist/tutorial/claude-settings.png +0 -0
  38. package/ui/dist/tutorial/header.png +0 -0
  39. package/ui/dist/tutorial/mcp-registry.png +0 -0
  40. package/ui/dist/tutorial/memory-view.png +0 -0
  41. package/ui/dist/tutorial/permissions.png +0 -0
  42. package/ui/dist/tutorial/plugins-view.png +0 -0
  43. package/ui/dist/tutorial/project-explorer.png +0 -0
  44. package/ui/dist/tutorial/projects-view.png +0 -0
  45. package/ui/dist/tutorial/sidebar.png +0 -0
  46. package/ui/dist/tutorial/tutorial-view.png +0 -0
  47. package/ui/dist/tutorial/workstreams-view.png +0 -0
  48. package/ui/routes/activity.js +58 -0
  49. package/ui/routes/commands.js +74 -0
  50. package/ui/routes/configs.js +329 -0
  51. package/ui/routes/env.js +40 -0
  52. package/ui/routes/file-explorer.js +668 -0
  53. package/ui/routes/index.js +41 -0
  54. package/ui/routes/mcp-discovery.js +235 -0
  55. package/ui/routes/memory.js +385 -0
  56. package/ui/routes/package.json +3 -0
  57. package/ui/routes/plugins.js +466 -0
  58. package/ui/routes/projects.js +198 -0
  59. package/ui/routes/registry.js +30 -0
  60. package/ui/routes/rules.js +74 -0
  61. package/ui/routes/search.js +125 -0
  62. package/ui/routes/settings.js +381 -0
  63. package/ui/routes/subprojects.js +208 -0
  64. package/ui/routes/tool-sync.js +127 -0
  65. package/ui/routes/updates.js +339 -0
  66. package/ui/routes/workstreams.js +224 -0
  67. package/ui/server.cjs +773 -0
  68. package/ui/terminal-server.cjs +160 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Activity tracking for workstream suggestions
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { loadWorkstreams } = require('./workstreams');
8
+
9
+ /**
10
+ * Get activity file path
11
+ */
12
+ function getActivityPath(installDir) {
13
+ return path.join(installDir, 'activity.json');
14
+ }
15
+
16
+ /**
17
+ * Get default activity structure
18
+ */
19
+ function getDefaultActivity() {
20
+ return {
21
+ sessions: [],
22
+ projectStats: {},
23
+ coActivity: {},
24
+ lastUpdated: null
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Load activity data
30
+ */
31
+ function loadActivity(installDir) {
32
+ const activityPath = getActivityPath(installDir);
33
+ if (fs.existsSync(activityPath)) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(activityPath, 'utf8'));
36
+ } catch (e) {
37
+ return getDefaultActivity();
38
+ }
39
+ }
40
+ return getDefaultActivity();
41
+ }
42
+
43
+ /**
44
+ * Save activity data
45
+ */
46
+ function saveActivity(installDir, data) {
47
+ const activityPath = getActivityPath(installDir);
48
+ const dir = path.dirname(activityPath);
49
+ if (!fs.existsSync(dir)) {
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ }
52
+ data.lastUpdated = new Date().toISOString();
53
+ fs.writeFileSync(activityPath, JSON.stringify(data, null, 2) + '\n');
54
+ }
55
+
56
+ /**
57
+ * Detect project root by finding .git or .claude folder
58
+ */
59
+ function detectProjectRoot(filePath) {
60
+ let dir = path.dirname(filePath);
61
+ const home = process.env.HOME || '';
62
+
63
+ while (dir && dir !== '/' && dir !== home) {
64
+ if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, '.claude'))) {
65
+ return dir;
66
+ }
67
+ dir = path.dirname(dir);
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Log activity from a Claude session
74
+ */
75
+ function activityLog(installDir, files, sessionId = null) {
76
+ const data = loadActivity(installDir);
77
+ const now = new Date().toISOString();
78
+
79
+ if (!sessionId) {
80
+ sessionId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
81
+ }
82
+
83
+ let session = data.sessions.find(s => s.id === sessionId);
84
+ if (!session) {
85
+ session = { id: sessionId, startedAt: now, files: [], projects: [] };
86
+ data.sessions.push(session);
87
+ }
88
+
89
+ const projectsInSession = new Set(session.projects);
90
+
91
+ for (const file of files) {
92
+ const rawPath = typeof file === 'string' ? file : file.path;
93
+ if (!rawPath) continue;
94
+ const filePath = path.resolve(rawPath.replace(/^~/, process.env.HOME || ''));
95
+ const action = typeof file === 'object' ? (file.action || 'access') : 'access';
96
+
97
+ session.files.push({ path: filePath, action, timestamp: now });
98
+
99
+ const projectPath = detectProjectRoot(filePath);
100
+ if (projectPath) {
101
+ projectsInSession.add(projectPath);
102
+
103
+ if (!data.projectStats[projectPath]) {
104
+ data.projectStats[projectPath] = { fileCount: 0, lastActive: now, sessionCount: 0 };
105
+ }
106
+ data.projectStats[projectPath].fileCount++;
107
+ data.projectStats[projectPath].lastActive = now;
108
+ }
109
+ }
110
+
111
+ session.projects = Array.from(projectsInSession);
112
+
113
+ const projects = session.projects;
114
+ for (let i = 0; i < projects.length; i++) {
115
+ for (let j = i + 1; j < projects.length; j++) {
116
+ const p1 = projects[i], p2 = projects[j];
117
+ if (!data.coActivity[p1]) data.coActivity[p1] = {};
118
+ if (!data.coActivity[p2]) data.coActivity[p2] = {};
119
+ data.coActivity[p1][p2] = (data.coActivity[p1][p2] || 0) + 1;
120
+ data.coActivity[p2][p1] = (data.coActivity[p2][p1] || 0) + 1;
121
+ }
122
+ }
123
+
124
+ if (data.sessions.length > 100) {
125
+ data.sessions = data.sessions.slice(-100);
126
+ }
127
+
128
+ saveActivity(installDir, data);
129
+ return { sessionId, filesLogged: files.length, projects: session.projects };
130
+ }
131
+
132
+ /**
133
+ * Get activity summary for UI
134
+ */
135
+ function activitySummary(installDir) {
136
+ const data = loadActivity(installDir);
137
+ const now = new Date();
138
+ const oneDayAgo = new Date(now - 24 * 60 * 60 * 1000);
139
+
140
+ const recentSessions = data.sessions.filter(s => new Date(s.startedAt) > oneDayAgo);
141
+
142
+ const projectActivity = Object.entries(data.projectStats)
143
+ .map(([projectPath, stats]) => ({
144
+ path: projectPath,
145
+ name: path.basename(projectPath),
146
+ ...stats,
147
+ isRecent: new Date(stats.lastActive) > oneDayAgo
148
+ }))
149
+ .sort((a, b) => new Date(b.lastActive) - new Date(a.lastActive));
150
+
151
+ const coActiveProjects = [];
152
+ for (const [project, coProjects] of Object.entries(data.coActivity)) {
153
+ for (const [otherProject, count] of Object.entries(coProjects)) {
154
+ if (count >= 2 && project < otherProject) {
155
+ coActiveProjects.push({
156
+ projects: [project, otherProject],
157
+ names: [path.basename(project), path.basename(otherProject)],
158
+ count
159
+ });
160
+ }
161
+ }
162
+ }
163
+ coActiveProjects.sort((a, b) => b.count - a.count);
164
+
165
+ const totalFiles = data.sessions.reduce((sum, s) => sum + (s.files?.length || 0), 0);
166
+
167
+ return {
168
+ totalSessions: data.sessions.length,
169
+ recentSessions: recentSessions.length,
170
+ totalFiles,
171
+ projectCount: Object.keys(data.projectStats).length,
172
+ topProjects: projectActivity.slice(0, 10),
173
+ projectActivity: projectActivity.slice(0, 20),
174
+ coActiveProjects: coActiveProjects.slice(0, 10),
175
+ lastUpdated: data.lastUpdated
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Generate a workstream name from project names
181
+ */
182
+ function generateWorkstreamName(projects) {
183
+ const names = projects.map(p => path.basename(p));
184
+ if (names.length <= 2) return names.join(' + ');
185
+ return `${names[0]} + ${names.length - 1} more`;
186
+ }
187
+
188
+ /**
189
+ * Suggest workstreams based on activity patterns
190
+ */
191
+ function activitySuggestWorkstreams(installDir) {
192
+ const data = loadActivity(installDir);
193
+ const workstreams = loadWorkstreams(installDir);
194
+ const suggestions = [];
195
+
196
+ const coGroups = new Map();
197
+
198
+ for (const session of data.sessions) {
199
+ if (session.projects.length >= 2) {
200
+ const key = session.projects.sort().join('|');
201
+ coGroups.set(key, (coGroups.get(key) || 0) + 1);
202
+ }
203
+ }
204
+
205
+ for (const [key, count] of coGroups) {
206
+ if (count >= 3) {
207
+ const projects = key.split('|');
208
+ const existingWs = workstreams.workstreams.find(ws =>
209
+ projects.every(p => ws.projects.includes(p))
210
+ );
211
+
212
+ if (!existingWs) {
213
+ const totalSessions = data.sessions.length;
214
+ const coActivityScore = totalSessions > 0 ? Math.round((count / totalSessions) * 100) : 0;
215
+
216
+ suggestions.push({
217
+ projects,
218
+ name: generateWorkstreamName(projects),
219
+ names: projects.map(p => path.basename(p)),
220
+ sessionCount: count,
221
+ coActivityScore: Math.min(coActivityScore, 100),
222
+ });
223
+ }
224
+ }
225
+ }
226
+
227
+ suggestions.sort((a, b) => b.sessionCount - a.sessionCount);
228
+ return suggestions.slice(0, 5);
229
+ }
230
+
231
+ /**
232
+ * Clear old activity data
233
+ */
234
+ function activityClear(installDir, olderThanDays = 30) {
235
+ const data = loadActivity(installDir);
236
+ const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
237
+
238
+ data.sessions = data.sessions.filter(s => new Date(s.startedAt) > cutoff);
239
+ data.projectStats = {};
240
+ data.coActivity = {};
241
+
242
+ for (const session of data.sessions) {
243
+ for (const file of session.files) {
244
+ const projectPath = detectProjectRoot(file.path);
245
+ if (projectPath) {
246
+ if (!data.projectStats[projectPath]) {
247
+ data.projectStats[projectPath] = { fileCount: 0, lastActive: session.startedAt, sessionCount: 0 };
248
+ }
249
+ data.projectStats[projectPath].fileCount++;
250
+ if (session.startedAt > data.projectStats[projectPath].lastActive) {
251
+ data.projectStats[projectPath].lastActive = session.startedAt;
252
+ }
253
+ }
254
+ }
255
+
256
+ const projects = session.projects;
257
+ for (let i = 0; i < projects.length; i++) {
258
+ for (let j = i + 1; j < projects.length; j++) {
259
+ const p1 = projects[i], p2 = projects[j];
260
+ if (!data.coActivity[p1]) data.coActivity[p1] = {};
261
+ if (!data.coActivity[p2]) data.coActivity[p2] = {};
262
+ data.coActivity[p1][p2] = (data.coActivity[p1][p2] || 0) + 1;
263
+ data.coActivity[p2][p1] = (data.coActivity[p2][p1] || 0) + 1;
264
+ }
265
+ }
266
+ }
267
+
268
+ saveActivity(installDir, data);
269
+ return { sessionsRemaining: data.sessions.length };
270
+ }
271
+
272
+ module.exports = {
273
+ getActivityPath,
274
+ getDefaultActivity,
275
+ loadActivity,
276
+ saveActivity,
277
+ detectProjectRoot,
278
+ activityLog,
279
+ activitySummary,
280
+ generateWorkstreamName,
281
+ activitySuggestWorkstreams,
282
+ activityClear,
283
+ };
package/lib/apply.js ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Apply commands for generating tool configs
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { execSync } = require('child_process');
8
+ const { TOOL_PATHS } = require('./constants');
9
+ const { loadJson, saveJson, loadEnvFile, interpolate, resolveEnvVars } = require('./utils');
10
+ const { findProjectRoot, findAllConfigs, mergeConfigs, findAllConfigsForTool } = require('./config');
11
+
12
+ /**
13
+ * Generate .mcp.json for a project (with hierarchical config merging)
14
+ */
15
+ function apply(registryPath, projectDir = null) {
16
+ const dir = projectDir || findProjectRoot() || process.cwd();
17
+
18
+ const registry = loadJson(registryPath);
19
+ if (!registry) {
20
+ console.error('Error: Could not load MCP registry from', registryPath);
21
+ return false;
22
+ }
23
+
24
+ const configLocations = findAllConfigs(dir);
25
+
26
+ if (configLocations.length === 0) {
27
+ console.error(`No .claude/mcps.json found in ${dir} or parent directories`);
28
+ console.error('Run: claude-config init');
29
+ return false;
30
+ }
31
+
32
+ const loadedConfigs = configLocations.map(loc => ({
33
+ ...loc,
34
+ config: loadJson(loc.configPath)
35
+ }));
36
+
37
+ if (loadedConfigs.length > 1) {
38
+ console.log('📚 Config hierarchy (merged):');
39
+ for (const { dir: d, configPath } of loadedConfigs) {
40
+ const relPath = d === process.env.HOME ? '~' : path.relative(process.cwd(), d) || '.';
41
+ console.log(` • ${relPath}/.claude/mcps.json`);
42
+ }
43
+ console.log('');
44
+ }
45
+
46
+ const mergedConfig = mergeConfigs(loadedConfigs);
47
+
48
+ const globalEnvPath = path.join(path.dirname(registryPath), '.env');
49
+ let env = loadEnvFile(globalEnvPath);
50
+
51
+ for (const { dir: d } of loadedConfigs) {
52
+ const envPath = path.join(d, '.claude', '.env');
53
+ env = { ...env, ...loadEnvFile(envPath) };
54
+ }
55
+
56
+ const output = { mcpServers: {} };
57
+
58
+ if (mergedConfig.include && Array.isArray(mergedConfig.include)) {
59
+ for (const name of mergedConfig.include) {
60
+ if (registry.mcpServers && registry.mcpServers[name]) {
61
+ output.mcpServers[name] = interpolate(registry.mcpServers[name], env);
62
+ } else {
63
+ console.warn(`Warning: MCP "${name}" not found in registry`);
64
+ }
65
+ }
66
+ }
67
+
68
+ if (mergedConfig.mcpServers) {
69
+ for (const [name, config] of Object.entries(mergedConfig.mcpServers)) {
70
+ if (name.startsWith('_')) continue;
71
+ output.mcpServers[name] = interpolate(config, env);
72
+ }
73
+ }
74
+
75
+ const outputPath = path.join(dir, '.mcp.json');
76
+ saveJson(outputPath, output);
77
+
78
+ const count = Object.keys(output.mcpServers).length;
79
+ console.log(`✓ Generated ${outputPath}`);
80
+ console.log(` └─ ${count} MCP(s): ${Object.keys(output.mcpServers).join(', ')}`);
81
+
82
+ // Generate settings.json with enabledPlugins if any are configured
83
+ if (mergedConfig.enabledPlugins && Object.keys(mergedConfig.enabledPlugins).length > 0) {
84
+ const settingsPath = path.join(dir, '.claude', 'settings.json');
85
+ let existingSettings = {};
86
+
87
+ // Load existing settings if present
88
+ if (fs.existsSync(settingsPath)) {
89
+ try {
90
+ existingSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
91
+ } catch (e) {
92
+ existingSettings = {};
93
+ }
94
+ }
95
+
96
+ // Merge enabledPlugins into settings
97
+ const settingsOutput = {
98
+ ...existingSettings,
99
+ enabledPlugins: mergedConfig.enabledPlugins
100
+ };
101
+
102
+ saveJson(settingsPath, settingsOutput);
103
+
104
+ const pluginCount = Object.entries(mergedConfig.enabledPlugins)
105
+ .filter(([_, enabled]) => enabled).length;
106
+ console.log(`✓ Generated ${settingsPath}`);
107
+ console.log(` └─ ${pluginCount} plugin(s) enabled`);
108
+ }
109
+
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * Generate MCP config for Antigravity
115
+ */
116
+ function applyForAntigravity(registryPath, projectDir = null) {
117
+ const dir = projectDir || findProjectRoot() || process.cwd();
118
+ const paths = TOOL_PATHS.antigravity;
119
+ const homeDir = process.env.HOME || '';
120
+
121
+ const registry = loadJson(registryPath);
122
+ if (!registry) {
123
+ console.error('Error: Could not load MCP registry');
124
+ return false;
125
+ }
126
+
127
+ const configLocations = findAllConfigsForTool('antigravity', dir);
128
+
129
+ if (configLocations.length === 0) {
130
+ console.log(` ℹ No .agent/mcps.json found - skipping Antigravity`);
131
+ console.log(` Create one with: mkdir -p .agent && echo '{"include":["filesystem"]}' > .agent/mcps.json`);
132
+ return true;
133
+ }
134
+
135
+ const loadedConfigs = configLocations.map(loc => ({
136
+ ...loc,
137
+ config: loadJson(loc.configPath)
138
+ }));
139
+ const mergedConfig = mergeConfigs(loadedConfigs);
140
+
141
+ let env = {};
142
+ const globalEnvPath = path.join(homeDir, '.gemini', 'antigravity', '.env');
143
+ env = { ...env, ...loadEnvFile(globalEnvPath) };
144
+
145
+ for (const { dir: d } of configLocations) {
146
+ if (d !== homeDir) {
147
+ const envPath = path.join(d, '.agent', '.env');
148
+ env = { ...env, ...loadEnvFile(envPath) };
149
+ }
150
+ }
151
+
152
+ const output = { mcpServers: {} };
153
+
154
+ if (mergedConfig.include && Array.isArray(mergedConfig.include)) {
155
+ for (const name of mergedConfig.include) {
156
+ if (registry.mcpServers && registry.mcpServers[name]) {
157
+ output.mcpServers[name] = resolveEnvVars(registry.mcpServers[name], env);
158
+ }
159
+ }
160
+ }
161
+
162
+ if (mergedConfig.mcpServers) {
163
+ for (const [name, config] of Object.entries(mergedConfig.mcpServers)) {
164
+ if (name.startsWith('_')) continue;
165
+ output.mcpServers[name] = resolveEnvVars(config, env);
166
+ }
167
+ }
168
+
169
+ const outputPath = paths.outputFile.replace(/^~/, homeDir);
170
+ const outputDir = path.dirname(outputPath);
171
+ if (!fs.existsSync(outputDir)) {
172
+ fs.mkdirSync(outputDir, { recursive: true });
173
+ }
174
+
175
+ saveJson(outputPath, output);
176
+
177
+ const count = Object.keys(output.mcpServers).length;
178
+ console.log(`✓ Generated ${outputPath} (Antigravity)`);
179
+ console.log(` └─ ${count} MCP(s): ${Object.keys(output.mcpServers).join(', ')}`);
180
+
181
+ return true;
182
+ }
183
+
184
+ /**
185
+ * Generate MCP config for Gemini CLI
186
+ */
187
+ function applyForGemini(registryPath, projectDir = null) {
188
+ const dir = projectDir || findProjectRoot() || process.cwd();
189
+ const paths = TOOL_PATHS.gemini;
190
+ const homeDir = process.env.HOME || '';
191
+
192
+ const registry = loadJson(registryPath);
193
+ if (!registry) {
194
+ console.error('Error: Could not load MCP registry');
195
+ return false;
196
+ }
197
+
198
+ const configLocations = findAllConfigsForTool('gemini', dir);
199
+
200
+ if (configLocations.length === 0) {
201
+ console.log(` ℹ No .gemini/mcps.json found - skipping Gemini CLI`);
202
+ console.log(` Create one with: mkdir -p .gemini && echo '{"include":["filesystem"]}' > .gemini/mcps.json`);
203
+ return true;
204
+ }
205
+
206
+ const loadedConfigs = configLocations.map(loc => ({
207
+ ...loc,
208
+ config: loadJson(loc.configPath)
209
+ }));
210
+ const mergedConfig = mergeConfigs(loadedConfigs);
211
+
212
+ let env = {};
213
+ const globalEnvPath = path.join(homeDir, '.gemini', '.env');
214
+ env = { ...env, ...loadEnvFile(globalEnvPath) };
215
+
216
+ for (const { dir: d } of configLocations) {
217
+ if (d !== homeDir) {
218
+ const envPath = path.join(d, '.gemini', '.env');
219
+ env = { ...env, ...loadEnvFile(envPath) };
220
+ }
221
+ }
222
+
223
+ const mcpServers = {};
224
+
225
+ if (mergedConfig.include && Array.isArray(mergedConfig.include)) {
226
+ for (const name of mergedConfig.include) {
227
+ if (registry.mcpServers && registry.mcpServers[name]) {
228
+ mcpServers[name] = interpolate(registry.mcpServers[name], env);
229
+ }
230
+ }
231
+ }
232
+
233
+ if (mergedConfig.mcpServers) {
234
+ for (const [name, config] of Object.entries(mergedConfig.mcpServers)) {
235
+ if (name.startsWith('_')) continue;
236
+ mcpServers[name] = interpolate(config, env);
237
+ }
238
+ }
239
+
240
+ // Generate global settings (~/.gemini/settings.json)
241
+ const globalOutputPath = paths.outputFile.replace(/^~/, homeDir);
242
+ const globalOutputDir = path.dirname(globalOutputPath);
243
+ if (!fs.existsSync(globalOutputDir)) {
244
+ fs.mkdirSync(globalOutputDir, { recursive: true });
245
+ }
246
+
247
+ let existingSettings = {};
248
+ if (fs.existsSync(globalOutputPath)) {
249
+ try {
250
+ existingSettings = JSON.parse(fs.readFileSync(globalOutputPath, 'utf8'));
251
+ } catch (e) {
252
+ existingSettings = {};
253
+ }
254
+ }
255
+
256
+ const globalOutput = {
257
+ ...existingSettings,
258
+ mcpServers
259
+ };
260
+
261
+ saveJson(globalOutputPath, globalOutput);
262
+
263
+ const count = Object.keys(mcpServers).length;
264
+ console.log(`✓ Generated ${globalOutputPath} (Gemini CLI - global)`);
265
+ console.log(` └─ ${count} MCP(s): ${Object.keys(mcpServers).join(', ')}`);
266
+
267
+ // Generate per-project settings (.gemini/settings.json)
268
+ const projectOutputDir = path.join(dir, '.gemini');
269
+ const projectOutputPath = path.join(projectOutputDir, 'settings.json');
270
+
271
+ if (!fs.existsSync(projectOutputDir)) {
272
+ fs.mkdirSync(projectOutputDir, { recursive: true });
273
+ }
274
+
275
+ // For per-project, only include mcpServers (not merge with existing)
276
+ const projectOutput = { mcpServers };
277
+ saveJson(projectOutputPath, projectOutput);
278
+
279
+ console.log(`✓ Generated ${projectOutputPath} (Gemini CLI - project)`);
280
+
281
+ return true;
282
+ }
283
+
284
+ /**
285
+ * Detect which AI coding tools are installed
286
+ */
287
+ function detectInstalledTools() {
288
+ const homeDir = process.env.HOME || '';
289
+ const results = {};
290
+
291
+ try {
292
+ execSync('which claude', { stdio: 'ignore' });
293
+ results.claude = { installed: true, method: 'command' };
294
+ } catch {
295
+ results.claude = {
296
+ installed: fs.existsSync(path.join(homeDir, '.claude')),
297
+ method: 'directory'
298
+ };
299
+ }
300
+
301
+ try {
302
+ execSync('which gemini', { stdio: 'ignore' });
303
+ results.gemini = { installed: true, method: 'command' };
304
+ } catch {
305
+ results.gemini = {
306
+ installed: fs.existsSync(path.join(homeDir, '.gemini')),
307
+ method: 'directory'
308
+ };
309
+ }
310
+
311
+ results.antigravity = {
312
+ installed: fs.existsSync(path.join(homeDir, '.gemini', 'antigravity')),
313
+ method: 'directory'
314
+ };
315
+
316
+ return results;
317
+ }
318
+
319
+ /**
320
+ * Apply config for multiple tools based on preferences
321
+ */
322
+ function applyForTools(registryPath, projectDir = null, tools = ['claude']) {
323
+ const results = {};
324
+
325
+ for (const tool of tools) {
326
+ if (tool === 'claude') {
327
+ results.claude = apply(registryPath, projectDir);
328
+ } else if (tool === 'gemini') {
329
+ results.gemini = applyForGemini(registryPath, projectDir);
330
+ } else if (tool === 'antigravity') {
331
+ results.antigravity = applyForAntigravity(registryPath, projectDir);
332
+ }
333
+ }
334
+
335
+ return results;
336
+ }
337
+
338
+ module.exports = {
339
+ apply,
340
+ applyForAntigravity,
341
+ applyForGemini,
342
+ detectInstalledTools,
343
+ applyForTools,
344
+ };