@regression-io/claude-config 0.32.1 → 0.32.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config-loader.js CHANGED
@@ -17,57 +17,23 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
- const { execSync } = require('child_process');
21
20
 
22
- const VERSION = '0.32.1';
23
-
24
- // Tool-specific path configurations
25
- const TOOL_PATHS = {
26
- claude: {
27
- name: 'Claude Code',
28
- icon: 'sparkles',
29
- color: 'orange',
30
- globalConfig: '~/.claude/mcps.json',
31
- globalSettings: '~/.claude/settings.json',
32
- projectFolder: '.claude',
33
- projectRules: '.claude/rules',
34
- projectCommands: '.claude/commands',
35
- projectWorkflows: '.claude/workflows',
36
- projectInstructions: 'CLAUDE.md',
37
- outputFile: '.mcp.json',
38
- supportsEnvInterpolation: true,
39
- },
40
- gemini: {
41
- name: 'Gemini CLI',
42
- icon: 'terminal',
43
- color: 'blue',
44
- globalConfig: '~/.gemini/settings.json', // MCP config is merged into settings.json under mcpServers key
45
- globalSettings: '~/.gemini/settings.json',
46
- globalMcpConfig: '~/.gemini/mcps.json', // Source config for MCPs (like Claude's)
47
- projectFolder: '.gemini',
48
- projectConfig: '.gemini/mcps.json', // Project-level MCP config
49
- projectRules: '.gemini',
50
- projectCommands: '.gemini/commands', // Uses TOML format
51
- projectInstructions: 'GEMINI.md',
52
- outputFile: '~/.gemini/settings.json', // Output merged into global settings
53
- supportsEnvInterpolation: true, // Gemini CLI likely supports ${VAR}
54
- mergeIntoSettings: true, // MCP config is merged into settings.json, not standalone
55
- },
56
- antigravity: {
57
- name: 'Antigravity',
58
- icon: 'rocket',
59
- color: 'purple',
60
- globalConfig: '~/.gemini/antigravity/mcp_config.json',
61
- globalMcpConfig: '~/.gemini/antigravity/mcps.json', // Source config for MCPs
62
- globalRules: '~/.gemini/GEMINI.md',
63
- projectFolder: '.agent',
64
- projectConfig: '.agent/mcps.json', // Project-level MCP config
65
- projectRules: '.agent/rules',
66
- projectInstructions: 'GEMINI.md',
67
- outputFile: '~/.gemini/antigravity/mcp_config.json', // Output to global config
68
- supportsEnvInterpolation: false, // Must resolve to actual values
69
- },
70
- };
21
+ // Import from modular lib
22
+ const { VERSION, TOOL_PATHS } = require('./lib/constants');
23
+ const { loadJson, saveJson, loadEnvFile, interpolate, resolveEnvVars, copyDirRecursive } = require('./lib/utils');
24
+ const { findProjectRoot, findAllConfigs, mergeConfigs, getConfigPath, collectFilesFromHierarchy, findAllConfigsForTool } = require('./lib/config');
25
+ const { listTemplates, findTemplate, resolveTemplateChain, copyTemplateFiles, trackAppliedTemplate, getAppliedTemplate } = require('./lib/templates');
26
+ const { apply, applyForAntigravity, applyForGemini, detectInstalledTools, applyForTools } = require('./lib/apply');
27
+ const { list, add, remove } = require('./lib/mcps');
28
+ const { registryAdd, registryRemove } = require('./lib/registry');
29
+ const { init, applyTemplate, show } = require('./lib/init');
30
+ const { memoryList, memoryInit, memoryAdd, memorySearch } = require('./lib/memory');
31
+ const { envList, envSet, envUnset } = require('./lib/env');
32
+ const { getProjectsRegistryPath, loadProjectsRegistry, saveProjectsRegistry, projectList, projectAdd, projectRemove } = require('./lib/projects');
33
+ const { getWorkstreamsPath, loadWorkstreams, saveWorkstreams, workstreamList, workstreamCreate, workstreamUpdate, workstreamDelete, workstreamUse, workstreamActive, workstreamAddProject, workstreamRemoveProject, workstreamInject, workstreamDetect, workstreamGet } = require('./lib/workstreams');
34
+ const { getActivityPath, getDefaultActivity, loadActivity, saveActivity, detectProjectRoot, activityLog, activitySummary, generateWorkstreamName, activitySuggestWorkstreams, activityClear } = require('./lib/activity');
35
+ const { getSmartSyncPath, loadSmartSyncPrefs, saveSmartSyncPrefs, smartSyncRememberChoice, smartSyncDismissNudge, smartSyncUpdateSettings, smartSyncDetect, smartSyncCheckNudge, smartSyncHandleAction, smartSyncStatus } = require('./lib/smart-sync');
36
+ const { runCli } = require('./lib/cli');
71
37
 
72
38
  class ClaudeConfigManager {
73
39
  constructor() {
@@ -89,2867 +55,191 @@ class ClaudeConfigManager {
89
55
  this.templatesDir = templatePaths.find(p => fs.existsSync(p)) || templatePaths[0];
90
56
  }
91
57
 
92
- /**
93
- * Load JSON file
94
- */
95
- loadJson(filePath) {
96
- try {
97
- if (!fs.existsSync(filePath)) return null;
98
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
99
- } catch (error) {
100
- console.error(`Error loading ${filePath}:`, error.message);
101
- return null;
102
- }
103
- }
104
-
105
- /**
106
- * Save JSON file
107
- */
108
- saveJson(filePath, data) {
109
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
110
- }
111
-
112
- /**
113
- * Load environment variables from .env file
114
- */
115
- loadEnvFile(envPath) {
116
- if (!fs.existsSync(envPath)) return {};
117
- const envVars = {};
118
- const lines = fs.readFileSync(envPath, 'utf8').split('\n');
119
- for (const line of lines) {
120
- const trimmed = line.trim();
121
- if (trimmed && !trimmed.startsWith('#')) {
122
- const eqIndex = trimmed.indexOf('=');
123
- if (eqIndex > 0) {
124
- const key = trimmed.substring(0, eqIndex).trim();
125
- let value = trimmed.substring(eqIndex + 1).trim();
126
- if ((value.startsWith('"') && value.endsWith('"')) ||
127
- (value.startsWith("'") && value.endsWith("'"))) {
128
- value = value.slice(1, -1);
129
- }
130
- envVars[key] = value;
131
- }
132
- }
133
- }
134
- return envVars;
135
- }
136
-
137
- /**
138
- * Interpolate ${VAR} in object values
139
- */
140
- interpolate(obj, env) {
141
- if (typeof obj === 'string') {
142
- return obj.replace(/\$\{([^}]+)\}/g, (match, varName) => {
143
- return env[varName] || process.env[varName] || match;
144
- });
145
- }
146
- if (Array.isArray(obj)) {
147
- return obj.map(v => this.interpolate(v, env));
148
- }
149
- if (obj !== null && typeof obj === 'object') {
150
- const result = {};
151
- for (const [k, v] of Object.entries(obj)) {
152
- result[k] = this.interpolate(v, env);
153
- }
154
- return result;
155
- }
156
- return obj;
157
- }
158
-
159
- /**
160
- * Find project root (has .claude/ directory)
161
- */
162
- findProjectRoot(startDir = process.cwd()) {
163
- let dir = path.resolve(startDir);
164
- const root = path.parse(dir).root;
165
- while (dir !== root) {
166
- if (fs.existsSync(path.join(dir, '.claude'))) {
167
- return dir;
168
- }
169
- dir = path.dirname(dir);
170
- }
171
- return null;
172
- }
173
-
174
- /**
175
- * Find ALL .claude/mcps.json configs from cwd up to root (and ~/.claude)
176
- * Returns array from root to leaf (so child overrides parent when merged)
177
- */
178
- findAllConfigs(startDir = process.cwd()) {
179
- const configs = [];
180
- let dir = path.resolve(startDir);
181
- const root = path.parse(dir).root;
182
- const homeDir = process.env.HOME || '';
183
-
184
- // Walk up directory tree
185
- while (dir !== root) {
186
- const configPath = path.join(dir, '.claude', 'mcps.json');
187
- if (fs.existsSync(configPath)) {
188
- configs.unshift({ dir, configPath }); // Add at beginning (root first)
189
- }
190
- dir = path.dirname(dir);
191
- }
192
-
193
- // Also check ~/.claude/mcps.json (global user config)
194
- const homeConfig = path.join(homeDir, '.claude', 'mcps.json');
195
- if (fs.existsSync(homeConfig)) {
196
- // Only add if not already included
197
- if (!configs.some(c => c.configPath === homeConfig)) {
198
- configs.unshift({ dir: homeDir, configPath: homeConfig });
199
- }
200
- }
201
-
202
- return configs;
203
- }
204
-
205
- /**
206
- * Merge multiple configs (later ones override earlier)
207
- */
208
- mergeConfigs(configs) {
209
- const merged = {
210
- include: [],
211
- mcpServers: {},
212
- template: null
213
- };
214
-
215
- for (const { config } of configs) {
216
- if (!config) continue;
217
-
218
- // Merge include arrays (dedupe)
219
- if (config.include && Array.isArray(config.include)) {
220
- for (const mcp of config.include) {
221
- if (!merged.include.includes(mcp)) {
222
- merged.include.push(mcp);
223
- }
224
- }
225
- }
226
-
227
- // Merge mcpServers (override)
228
- if (config.mcpServers) {
229
- Object.assign(merged.mcpServers, config.mcpServers);
230
- }
231
-
232
- // Take the most specific template
233
- if (config.template) {
234
- merged.template = config.template;
235
- }
236
- }
237
-
238
- return merged;
239
- }
240
-
241
- /**
242
- * Get project config path
243
- */
244
- getConfigPath(projectDir = null) {
245
- const dir = projectDir || this.findProjectRoot() || process.cwd();
246
- return path.join(dir, '.claude', 'mcps.json');
247
- }
248
-
249
- /**
250
- * Collect files (rules or commands) from all directories in hierarchy
251
- * Returns array of { file, source, fullPath } with child files overriding parent
252
- */
253
- collectFilesFromHierarchy(configLocations, subdir) {
254
- const fileMap = new Map(); // filename -> { file, source, fullPath }
255
-
256
- // Process from root to leaf (so child overrides parent)
257
- for (const { dir } of configLocations) {
258
- const dirPath = path.join(dir, '.claude', subdir);
259
- if (fs.existsSync(dirPath)) {
260
- const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.md'));
261
- for (const file of files) {
262
- fileMap.set(file, {
263
- file,
264
- source: dir,
265
- fullPath: path.join(dirPath, file)
266
- });
267
- }
268
- }
269
- }
58
+ // Utils
59
+ loadJson(filePath) { return loadJson(filePath); }
60
+ saveJson(filePath, data) { return saveJson(filePath, data); }
61
+ loadEnvFile(envPath) { return loadEnvFile(envPath); }
62
+ interpolate(obj, env) { return interpolate(obj, env); }
63
+ resolveEnvVars(obj, env) { return resolveEnvVars(obj, env); }
64
+ copyDirRecursive(src, dest) { return copyDirRecursive(src, dest); }
270
65
 
271
- return Array.from(fileMap.values());
272
- }
66
+ // Config
67
+ findProjectRoot(startDir) { return findProjectRoot(startDir); }
68
+ findAllConfigs(startDir) { return findAllConfigs(startDir); }
69
+ mergeConfigs(configs) { return mergeConfigs(configs); }
70
+ getConfigPath(projectDir) { return getConfigPath(this.installDir, projectDir); }
71
+ collectFilesFromHierarchy(configLocations, subdir) { return collectFilesFromHierarchy(configLocations, subdir); }
72
+ findAllConfigsForTool(toolId, startDir) { return findAllConfigsForTool(toolId, startDir); }
273
73
 
274
- /**
275
- * Get all rules from hierarchy (for external use)
276
- */
277
74
  getAllRules(startDir = process.cwd()) {
278
- const configLocations = this.findAllConfigs(startDir);
279
- return this.collectFilesFromHierarchy(configLocations, 'rules');
75
+ const configLocations = findAllConfigs(startDir);
76
+ return collectFilesFromHierarchy(configLocations, 'rules');
280
77
  }
281
78
 
282
- /**
283
- * Get all commands from hierarchy (for external use)
284
- */
285
79
  getAllCommands(startDir = process.cwd()) {
286
- const configLocations = this.findAllConfigs(startDir);
287
- return this.collectFilesFromHierarchy(configLocations, 'commands');
288
- }
289
-
290
- // ===========================================================================
291
- // TEMPLATE SYSTEM
292
- // ===========================================================================
293
-
294
- /**
295
- * List available templates
296
- */
297
- listTemplates() {
298
- console.log('\n📋 Available Templates:\n');
299
-
300
- const categories = [
301
- { name: 'Frameworks', path: 'frameworks' },
302
- { name: 'Languages', path: 'languages' },
303
- { name: 'Composites (Monorepos)', path: 'composites' }
304
- ];
305
-
306
- for (const category of categories) {
307
- const categoryPath = path.join(this.templatesDir, category.path);
308
- if (!fs.existsSync(categoryPath)) continue;
309
-
310
- console.log(` ${category.name}:`);
311
- const templates = fs.readdirSync(categoryPath).filter(f =>
312
- fs.statSync(path.join(categoryPath, f)).isDirectory()
313
- );
314
-
315
- for (const template of templates) {
316
- const templateJson = this.loadJson(path.join(categoryPath, template, 'template.json'));
317
- const desc = templateJson?.description || '';
318
- console.log(` • ${category.path}/${template}${desc ? ` - ${desc}` : ''}`);
319
- }
320
- console.log('');
321
- }
322
-
323
- console.log(' Usage: claude-config init --template <template-name>');
324
- console.log(' Example: claude-config init --template fastapi');
325
- console.log(' claude-config init --template fastapi-react-ts\n');
326
- }
327
-
328
- /**
329
- * Find a template by name (searches all categories)
330
- */
331
- findTemplate(name) {
332
- // Direct path
333
- if (name.includes('/')) {
334
- const templatePath = path.join(this.templatesDir, name);
335
- if (fs.existsSync(path.join(templatePath, 'template.json'))) {
336
- return templatePath;
337
- }
338
- }
339
-
340
- // Check root level first (for "universal")
341
- const rootPath = path.join(this.templatesDir, name);
342
- if (fs.existsSync(path.join(rootPath, 'template.json'))) {
343
- return rootPath;
344
- }
345
-
346
- // Search in categories
347
- const categories = ['frameworks', 'languages', 'composites'];
348
- for (const category of categories) {
349
- const templatePath = path.join(this.templatesDir, category, name);
350
- if (fs.existsSync(path.join(templatePath, 'template.json'))) {
351
- return templatePath;
352
- }
353
- }
354
-
355
- return null;
356
- }
357
-
358
- /**
359
- * Resolve all templates to include (following includes chain)
360
- */
361
- resolveTemplateChain(templatePath, visited = new Set()) {
362
- if (visited.has(templatePath)) return [];
363
- visited.add(templatePath);
364
-
365
- const templateJson = this.loadJson(path.join(templatePath, 'template.json'));
366
- if (!templateJson) return [templatePath];
367
-
368
- const chain = [];
369
-
370
- // Process includes first (base templates)
371
- if (templateJson.includes && Array.isArray(templateJson.includes)) {
372
- for (const include of templateJson.includes) {
373
- const includePath = this.findTemplate(include);
374
- if (includePath) {
375
- chain.push(...this.resolveTemplateChain(includePath, visited));
376
- }
377
- }
378
- }
379
-
380
- // Then add this template
381
- chain.push(templatePath);
382
-
383
- return chain;
384
- }
385
-
386
- /**
387
- * Copy template files to project (won't overwrite existing)
388
- */
389
- copyTemplateFiles(templatePath, projectDir, options = {}) {
390
- const { force = false, verbose = true } = options;
391
- const rulesDir = path.join(templatePath, 'rules');
392
- const commandsDir = path.join(templatePath, 'commands');
393
- const projectRulesDir = path.join(projectDir, '.claude', 'rules');
394
- const projectCommandsDir = path.join(projectDir, '.claude', 'commands');
395
-
396
- let copied = 0;
397
- let skipped = 0;
398
-
399
- // Copy rules
400
- if (fs.existsSync(rulesDir)) {
401
- if (!fs.existsSync(projectRulesDir)) {
402
- fs.mkdirSync(projectRulesDir, { recursive: true });
403
- }
404
-
405
- for (const file of fs.readdirSync(rulesDir)) {
406
- if (!file.endsWith('.md')) continue;
407
- const src = path.join(rulesDir, file);
408
- const dest = path.join(projectRulesDir, file);
409
-
410
- if (fs.existsSync(dest) && !force) {
411
- skipped++;
412
- if (verbose) console.log(` ⏭ rules/${file} (exists)`);
413
- } else {
414
- fs.copyFileSync(src, dest);
415
- copied++;
416
- if (verbose) console.log(` ✓ rules/${file}`);
417
- }
418
- }
419
- }
420
-
421
- // Copy commands
422
- if (fs.existsSync(commandsDir)) {
423
- if (!fs.existsSync(projectCommandsDir)) {
424
- fs.mkdirSync(projectCommandsDir, { recursive: true });
425
- }
426
-
427
- for (const file of fs.readdirSync(commandsDir)) {
428
- if (!file.endsWith('.md')) continue;
429
- const src = path.join(commandsDir, file);
430
- const dest = path.join(projectCommandsDir, file);
431
-
432
- if (fs.existsSync(dest) && !force) {
433
- skipped++;
434
- if (verbose) console.log(` ⏭ commands/${file} (exists)`);
435
- } else {
436
- fs.copyFileSync(src, dest);
437
- copied++;
438
- if (verbose) console.log(` ✓ commands/${file}`);
439
- }
440
- }
441
- }
442
-
443
- return { copied, skipped };
444
- }
445
-
446
- // ===========================================================================
447
- // CORE COMMANDS
448
- // ===========================================================================
449
-
450
- /**
451
- * Generate .mcp.json for a project (with hierarchical config merging)
452
- */
453
- apply(projectDir = null) {
454
- const dir = projectDir || this.findProjectRoot() || process.cwd();
455
-
456
- const registry = this.loadJson(this.registryPath);
457
- if (!registry) {
458
- console.error('Error: Could not load MCP registry from', this.registryPath);
459
- return false;
460
- }
461
-
462
- // Find and load all configs in hierarchy
463
- const configLocations = this.findAllConfigs(dir);
464
-
465
- if (configLocations.length === 0) {
466
- console.error(`No .claude/mcps.json found in ${dir} or parent directories`);
467
- console.error('Run: claude-config init');
468
- return false;
469
- }
470
-
471
- // Load all configs
472
- const loadedConfigs = configLocations.map(loc => ({
473
- ...loc,
474
- config: this.loadJson(loc.configPath)
475
- }));
476
-
477
- // Show config hierarchy if multiple configs found
478
- if (loadedConfigs.length > 1) {
479
- console.log('📚 Config hierarchy (merged):');
480
- for (const { dir: d, configPath } of loadedConfigs) {
481
- const relPath = d === process.env.HOME ? '~' : path.relative(process.cwd(), d) || '.';
482
- console.log(` • ${relPath}/.claude/mcps.json`);
483
- }
484
- console.log('');
485
- }
486
-
487
- // Merge all configs
488
- const mergedConfig = this.mergeConfigs(loadedConfigs);
489
-
490
- // Collect env vars from all levels (child overrides parent)
491
- const globalEnvPath = path.join(path.dirname(this.registryPath), '.env');
492
- let env = this.loadEnvFile(globalEnvPath);
493
-
494
- for (const { dir: d } of loadedConfigs) {
495
- const envPath = path.join(d, '.claude', '.env');
496
- env = { ...env, ...this.loadEnvFile(envPath) };
497
- }
498
-
499
- const output = { mcpServers: {} };
500
-
501
- // Add MCPs from include list
502
- if (mergedConfig.include && Array.isArray(mergedConfig.include)) {
503
- for (const name of mergedConfig.include) {
504
- if (registry.mcpServers && registry.mcpServers[name]) {
505
- output.mcpServers[name] = this.interpolate(registry.mcpServers[name], env);
506
- } else {
507
- console.warn(`Warning: MCP "${name}" not found in registry`);
508
- }
509
- }
510
- }
511
-
512
- // Add custom mcpServers (override registry)
513
- if (mergedConfig.mcpServers) {
514
- for (const [name, config] of Object.entries(mergedConfig.mcpServers)) {
515
- if (name.startsWith('_')) continue;
516
- output.mcpServers[name] = this.interpolate(config, env);
517
- }
518
- }
519
-
520
- const outputPath = path.join(dir, '.mcp.json');
521
- this.saveJson(outputPath, output);
522
-
523
- const count = Object.keys(output.mcpServers).length;
524
- console.log(`✓ Generated ${outputPath}`);
525
- console.log(` └─ ${count} MCP(s): ${Object.keys(output.mcpServers).join(', ')}`);
526
-
527
- return true;
528
- }
529
-
530
- /**
531
- * Resolve ${VAR} to actual values (for tools that don't support interpolation)
532
- */
533
- resolveEnvVars(obj, env) {
534
- if (typeof obj === 'string') {
535
- return obj.replace(/\$\{([^}]+)\}/g, (match, varName) => {
536
- const value = env[varName] || process.env[varName];
537
- if (!value) {
538
- console.warn(`Warning: Environment variable ${varName} not set`);
539
- return ''; // Return empty instead of keeping ${VAR}
540
- }
541
- return value;
542
- });
543
- }
544
- if (Array.isArray(obj)) {
545
- return obj.map(v => this.resolveEnvVars(v, env));
546
- }
547
- if (obj && typeof obj === 'object') {
548
- const result = {};
549
- for (const [key, value] of Object.entries(obj)) {
550
- result[key] = this.resolveEnvVars(value, env);
551
- }
552
- return result;
553
- }
554
- return obj;
555
- }
556
-
557
- /**
558
- * Generate MCP config for Antigravity
559
- */
560
- /**
561
- * Generate MCP config for Antigravity
562
- * Reads from .agent/mcps.json (NOT .claude/mcps.json)
563
- */
564
- applyForAntigravity(projectDir = null) {
565
- const dir = projectDir || this.findProjectRoot() || process.cwd();
566
- const paths = TOOL_PATHS.antigravity;
567
- const homeDir = process.env.HOME || '';
568
-
569
- const registry = this.loadJson(this.registryPath);
570
- if (!registry) {
571
- console.error('Error: Could not load MCP registry');
80
+ const configLocations = findAllConfigs(startDir);
81
+ return collectFilesFromHierarchy(configLocations, 'commands');
82
+ }
83
+
84
+ // Templates
85
+ listTemplates() { return listTemplates(this.templatesDir); }
86
+ findTemplate(name) { return findTemplate(this.templatesDir, name); }
87
+ resolveTemplateChain(templatePath, visited) { return resolveTemplateChain(this.templatesDir, templatePath, visited); }
88
+ copyTemplateFiles(templatePath, projectDir, options) { return copyTemplateFiles(templatePath, projectDir, options); }
89
+ trackAppliedTemplate(dir, templateName) { return trackAppliedTemplate(dir, templateName); }
90
+ getAppliedTemplate(dir) { return getAppliedTemplate(dir); }
91
+
92
+ // Apply
93
+ apply(projectDir) { return apply(this.registryPath, projectDir); }
94
+ applyForAntigravity(projectDir) { return applyForAntigravity(this.registryPath, projectDir); }
95
+ applyForGemini(projectDir) { return applyForGemini(this.registryPath, projectDir); }
96
+ detectInstalledTools() { return detectInstalledTools(); }
97
+ getToolPaths() { return TOOL_PATHS; }
98
+ applyForTools(projectDir, tools) { return applyForTools(this.registryPath, projectDir, tools); }
99
+
100
+ // MCPs
101
+ list() { return list(this.registryPath); }
102
+ add(mcpNames) { return add(this.registryPath, this.installDir, mcpNames); }
103
+ remove(mcpNames) { return remove(this.installDir, mcpNames); }
104
+
105
+ // Registry
106
+ registryAdd(name, configJson) { return registryAdd(this.registryPath, name, configJson); }
107
+ registryRemove(name) { return registryRemove(this.registryPath, name); }
108
+
109
+ // Init
110
+ init(projectDir, templateName) { return init(this.templatesDir, this.registryPath, projectDir, templateName); }
111
+ applyTemplate(templateName, projectDir) { return applyTemplate(this.templatesDir, templateName, projectDir); }
112
+ show(projectDir) { return show(projectDir); }
113
+
114
+ // Memory
115
+ memoryList(projectDir) { return memoryList(projectDir); }
116
+ memoryInit(projectDir) { return memoryInit(projectDir); }
117
+ memoryAdd(type, content, projectDir) { return memoryAdd(type, content, projectDir); }
118
+ memorySearch(query, projectDir) { return memorySearch(query, projectDir); }
119
+
120
+ // Env
121
+ envList(projectDir) { return envList(projectDir); }
122
+ envSet(key, value, projectDir) { return envSet(key, value, projectDir); }
123
+ envUnset(key, projectDir) { return envUnset(key, projectDir); }
124
+
125
+ // Projects
126
+ getProjectsRegistryPath() { return getProjectsRegistryPath(this.installDir); }
127
+ loadProjectsRegistry() { return loadProjectsRegistry(this.installDir); }
128
+ saveProjectsRegistry(registry) { return saveProjectsRegistry(this.installDir, registry); }
129
+ projectList() { return projectList(this.installDir); }
130
+ projectAdd(projectPath, name) { return projectAdd(this.installDir, projectPath, name); }
131
+ projectRemove(nameOrPath) { return projectRemove(this.installDir, nameOrPath); }
132
+
133
+ // Workstreams
134
+ getWorkstreamsPath() { return getWorkstreamsPath(this.installDir); }
135
+ loadWorkstreams() { return loadWorkstreams(this.installDir); }
136
+ saveWorkstreams(data) { return saveWorkstreams(this.installDir, data); }
137
+ workstreamList() { return workstreamList(this.installDir); }
138
+ workstreamCreate(name, projects, rules) { return workstreamCreate(this.installDir, name, projects, rules); }
139
+ workstreamUpdate(idOrName, updates) { return workstreamUpdate(this.installDir, idOrName, updates); }
140
+ workstreamDelete(idOrName) { return workstreamDelete(this.installDir, idOrName); }
141
+ workstreamUse(idOrName) { return workstreamUse(this.installDir, idOrName); }
142
+ workstreamActive() { return workstreamActive(this.installDir); }
143
+ workstreamAddProject(idOrName, projectPath) { return workstreamAddProject(this.installDir, idOrName, projectPath); }
144
+ workstreamRemoveProject(idOrName, projectPath) { return workstreamRemoveProject(this.installDir, idOrName, projectPath); }
145
+ workstreamInject(silent) { return workstreamInject(this.installDir, silent); }
146
+ workstreamDetect(dir) { return workstreamDetect(this.installDir, dir); }
147
+ workstreamGet(id) { return workstreamGet(this.installDir, id); }
148
+
149
+ // Activity
150
+ getActivityPath() { return getActivityPath(this.installDir); }
151
+ loadActivity() { return loadActivity(this.installDir); }
152
+ getDefaultActivity() { return getDefaultActivity(); }
153
+ saveActivity(data) { return saveActivity(this.installDir, data); }
154
+ detectProjectRoot(filePath) { return detectProjectRoot(filePath); }
155
+ activityLog(files, sessionId) { return activityLog(this.installDir, files, sessionId); }
156
+ activitySummary() { return activitySummary(this.installDir); }
157
+ generateWorkstreamName(projects) { return generateWorkstreamName(projects); }
158
+ activitySuggestWorkstreams() { return activitySuggestWorkstreams(this.installDir); }
159
+ activityClear(olderThanDays) { return activityClear(this.installDir, olderThanDays); }
160
+
161
+ // Smart Sync
162
+ getSmartSyncPath() { return getSmartSyncPath(this.installDir); }
163
+ loadSmartSyncPrefs() { return loadSmartSyncPrefs(this.installDir); }
164
+ saveSmartSyncPrefs(prefs) { return saveSmartSyncPrefs(this.installDir, prefs); }
165
+ smartSyncRememberChoice(projectPath, workstreamId, choice) { return smartSyncRememberChoice(this.installDir, projectPath, workstreamId, choice); }
166
+ smartSyncDismissNudge(nudgeKey) { return smartSyncDismissNudge(this.installDir, nudgeKey); }
167
+ smartSyncUpdateSettings(settings) { return smartSyncUpdateSettings(this.installDir, settings); }
168
+ smartSyncDetect(currentProjects) { return smartSyncDetect(this.installDir, currentProjects); }
169
+ smartSyncCheckNudge(currentProjects) { return smartSyncCheckNudge(this.installDir, currentProjects); }
170
+ smartSyncHandleAction(nudgeKey, action, context) { return smartSyncHandleAction(this.installDir, nudgeKey, action, context); }
171
+ smartSyncStatus() { return smartSyncStatus(this.installDir); }
172
+
173
+ // Update
174
+ update(sourcePath) {
175
+ if (!sourcePath) {
176
+ console.error('Usage: claude-config update /path/to/claude-config');
177
+ console.log('\nThis copies updated files from the source to your installation.');
572
178
  return false;
573
179
  }
574
180
 
575
- // Find and load all configs in hierarchy (from .agent folders)
576
- const configLocations = this.findAllConfigsForTool('antigravity', dir);
577
-
578
- if (configLocations.length === 0) {
579
- // No Antigravity-specific config found - skip silently
580
- console.log(` ℹ No .agent/mcps.json found - skipping Antigravity`);
581
- console.log(` Create one with: mkdir -p .agent && echo '{"include":["filesystem"]}' > .agent/mcps.json`);
582
- return true; // Not an error, just no config
583
- }
584
-
585
- // Load all configs and merge
586
- const loadedConfigs = configLocations.map(loc => ({
587
- ...loc,
588
- config: this.loadJson(loc.configPath)
589
- }));
590
- const mergedConfig = this.mergeConfigs(loadedConfigs);
591
-
592
- // Collect env vars from Antigravity-specific .env files
593
- let env = {};
594
-
595
- // Global env from ~/.gemini/antigravity/.env
596
- const globalEnvPath = path.join(homeDir, '.gemini', 'antigravity', '.env');
597
- env = { ...env, ...this.loadEnvFile(globalEnvPath) };
598
-
599
- // Project-level env files
600
- for (const { dir: d } of configLocations) {
601
- if (d !== homeDir) {
602
- const envPath = path.join(d, '.agent', '.env');
603
- env = { ...env, ...this.loadEnvFile(envPath) };
604
- }
605
- }
606
-
607
- const output = { mcpServers: {} };
608
-
609
- // Add MCPs from include list
610
- if (mergedConfig.include && Array.isArray(mergedConfig.include)) {
611
- for (const name of mergedConfig.include) {
612
- if (registry.mcpServers && registry.mcpServers[name]) {
613
- // Resolve env vars to actual values (Antigravity doesn't support ${VAR})
614
- output.mcpServers[name] = this.resolveEnvVars(registry.mcpServers[name], env);
615
- }
616
- }
617
- }
618
-
619
- // Add custom mcpServers
620
- if (mergedConfig.mcpServers) {
621
- for (const [name, config] of Object.entries(mergedConfig.mcpServers)) {
622
- if (name.startsWith('_')) continue;
623
- output.mcpServers[name] = this.resolveEnvVars(config, env);
624
- }
625
- }
626
-
627
- // Expand ~ in output path
628
- const outputPath = paths.outputFile.replace(/^~/, homeDir);
629
-
630
- // Ensure directory exists
631
- const outputDir = path.dirname(outputPath);
632
- if (!fs.existsSync(outputDir)) {
633
- fs.mkdirSync(outputDir, { recursive: true });
634
- }
635
-
636
- this.saveJson(outputPath, output);
637
-
638
- const count = Object.keys(output.mcpServers).length;
639
- console.log(`✓ Generated ${outputPath} (Antigravity)`);
640
- console.log(` └─ ${count} MCP(s): ${Object.keys(output.mcpServers).join(', ')}`);
641
-
642
- return true;
643
- }
644
-
645
- /**
646
- * Find all MCP configs for a specific tool in hierarchy
647
- * Similar to findAllConfigs but uses tool-specific folder paths
648
- */
649
- findAllConfigsForTool(toolId, startDir = null) {
650
- const tool = TOOL_PATHS[toolId];
651
- if (!tool) return [];
652
-
653
- const dir = startDir || this.findProjectRoot() || process.cwd();
654
- const homeDir = process.env.HOME || '';
655
- const configs = [];
656
-
657
- // Walk up from project to find project-level configs
658
- let currentDir = dir;
659
- const root = path.parse(currentDir).root;
660
-
661
- while (currentDir && currentDir !== root && currentDir !== homeDir) {
662
- const configPath = path.join(currentDir, tool.projectConfig || `${tool.projectFolder}/mcps.json`);
663
- if (fs.existsSync(configPath)) {
664
- configs.push({
665
- dir: currentDir,
666
- configPath,
667
- type: 'project'
668
- });
669
- }
670
- currentDir = path.dirname(currentDir);
671
- }
672
-
673
- // Check for global config
674
- if (tool.globalMcpConfig) {
675
- const globalPath = tool.globalMcpConfig.replace(/^~/, homeDir);
676
- if (fs.existsSync(globalPath)) {
677
- configs.push({
678
- dir: homeDir,
679
- configPath: globalPath,
680
- type: 'global'
681
- });
682
- }
683
- }
684
-
685
- // Reverse so global is first, then parent dirs, then project dir
686
- return configs.reverse();
687
- }
688
-
689
- /**
690
- * Generate MCP config for Gemini CLI
691
- * Gemini CLI stores MCP config inside ~/.gemini/settings.json under mcpServers key
692
- * Reads from .gemini/mcps.json (NOT .claude/mcps.json)
693
- */
694
- applyForGemini(projectDir = null) {
695
- const dir = projectDir || this.findProjectRoot() || process.cwd();
696
- const paths = TOOL_PATHS.gemini;
697
- const homeDir = process.env.HOME || '';
698
-
699
- const registry = this.loadJson(this.registryPath);
700
- if (!registry) {
701
- console.error('Error: Could not load MCP registry');
181
+ if (!fs.existsSync(sourcePath)) {
182
+ console.error(`Source not found: ${sourcePath}`);
702
183
  return false;
703
184
  }
704
185
 
705
- // Find and load all configs in hierarchy (from .gemini folders)
706
- const configLocations = this.findAllConfigsForTool('gemini', dir);
707
-
708
- if (configLocations.length === 0) {
709
- // No Gemini-specific config found - skip silently or create empty
710
- console.log(` ℹ No .gemini/mcps.json found - skipping Gemini CLI`);
711
- console.log(` Create one with: mkdir -p .gemini && echo '{"include":["filesystem"]}' > .gemini/mcps.json`);
712
- return true; // Not an error, just no config
713
- }
714
-
715
- // Load all configs and merge
716
- const loadedConfigs = configLocations.map(loc => ({
717
- ...loc,
718
- config: this.loadJson(loc.configPath)
719
- }));
720
- const mergedConfig = this.mergeConfigs(loadedConfigs);
721
-
722
- // Collect env vars from Gemini-specific .env files
723
- let env = {};
724
-
725
- // Global env from ~/.gemini/.env
726
- const globalEnvPath = path.join(homeDir, '.gemini', '.env');
727
- env = { ...env, ...this.loadEnvFile(globalEnvPath) };
728
-
729
- // Project-level env files
730
- for (const { dir: d } of configLocations) {
731
- if (d !== homeDir) {
732
- const envPath = path.join(d, '.gemini', '.env');
733
- env = { ...env, ...this.loadEnvFile(envPath) };
734
- }
735
- }
186
+ const files = [
187
+ 'config-loader.js',
188
+ 'shared/mcp-registry.json',
189
+ 'shell/claude-config.zsh'
190
+ ];
736
191
 
737
- const mcpServers = {};
192
+ let updated = 0;
193
+ for (const file of files) {
194
+ const src = path.join(sourcePath, file);
195
+ const dest = path.join(this.installDir, file);
738
196
 
739
- // Add MCPs from include list
740
- if (mergedConfig.include && Array.isArray(mergedConfig.include)) {
741
- for (const name of mergedConfig.include) {
742
- if (registry.mcpServers && registry.mcpServers[name]) {
743
- // Keep ${VAR} interpolation for Gemini CLI (it supports it)
744
- mcpServers[name] = this.interpolate(registry.mcpServers[name], env);
197
+ if (fs.existsSync(src)) {
198
+ const destDir = path.dirname(dest);
199
+ if (!fs.existsSync(destDir)) {
200
+ fs.mkdirSync(destDir, { recursive: true });
745
201
  }
202
+ fs.copyFileSync(src, dest);
203
+ console.log(`✓ Updated ${file}`);
204
+ updated++;
746
205
  }
747
206
  }
748
207
 
749
- // Add custom mcpServers
750
- if (mergedConfig.mcpServers) {
751
- for (const [name, config] of Object.entries(mergedConfig.mcpServers)) {
752
- if (name.startsWith('_')) continue;
753
- mcpServers[name] = this.interpolate(config, env);
754
- }
755
- }
756
-
757
- // Expand ~ in output path
758
- const outputPath = paths.outputFile.replace(/^~/, homeDir);
759
-
760
- // Ensure directory exists
761
- const outputDir = path.dirname(outputPath);
762
- if (!fs.existsSync(outputDir)) {
763
- fs.mkdirSync(outputDir, { recursive: true });
764
- }
765
-
766
- // Load existing settings.json and merge (preserve other keys)
767
- let existingSettings = {};
768
- if (fs.existsSync(outputPath)) {
769
- try {
770
- existingSettings = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
771
- } catch (e) {
772
- // If corrupt, start fresh
773
- existingSettings = {};
774
- }
775
- }
776
-
777
- // Merge mcpServers into existing settings
778
- const output = {
779
- ...existingSettings,
780
- mcpServers
781
- };
782
-
783
- this.saveJson(outputPath, output);
784
-
785
- const count = Object.keys(mcpServers).length;
786
- console.log(`✓ Generated ${outputPath} (Gemini CLI)`);
787
- console.log(` └─ ${count} MCP(s): ${Object.keys(mcpServers).join(', ')}`);
788
-
789
- return true;
790
- }
791
-
792
- /**
793
- * Detect which AI coding tools are installed
794
- */
795
- detectInstalledTools() {
796
- const homeDir = process.env.HOME || '';
797
- const results = {};
798
-
799
- // Check Claude Code - look for claude command or ~/.claude directory
800
- try {
801
- execSync('which claude', { stdio: 'ignore' });
802
- results.claude = { installed: true, method: 'command' };
803
- } catch {
804
- results.claude = {
805
- installed: fs.existsSync(path.join(homeDir, '.claude')),
806
- method: 'directory'
807
- };
808
- }
809
-
810
- // Check Gemini CLI - look for gemini command or ~/.gemini directory
811
- try {
812
- execSync('which gemini', { stdio: 'ignore' });
813
- results.gemini = { installed: true, method: 'command' };
814
- } catch {
815
- results.gemini = {
816
- installed: fs.existsSync(path.join(homeDir, '.gemini')),
817
- method: 'directory'
818
- };
208
+ // Copy templates directory
209
+ const srcTemplates = path.join(sourcePath, 'templates');
210
+ const destTemplates = path.join(this.installDir, 'templates');
211
+ if (fs.existsSync(srcTemplates)) {
212
+ copyDirRecursive(srcTemplates, destTemplates);
213
+ console.log(`✓ Updated templates/`);
214
+ updated++;
819
215
  }
820
216
 
821
- // Check Antigravity - look for ~/.gemini/antigravity directory
822
- results.antigravity = {
823
- installed: fs.existsSync(path.join(homeDir, '.gemini', 'antigravity')),
824
- method: 'directory'
825
- };
826
-
827
- return results;
828
- }
829
-
830
- /**
831
- * Get tool paths configuration
832
- */
833
- getToolPaths() {
834
- return TOOL_PATHS;
835
- }
836
-
837
- /**
838
- * Apply config for multiple tools based on preferences
839
- */
840
- applyForTools(projectDir = null, tools = ['claude']) {
841
- const results = {};
842
-
843
- for (const tool of tools) {
844
- if (tool === 'claude') {
845
- results.claude = this.apply(projectDir);
846
- } else if (tool === 'gemini') {
847
- results.gemini = this.applyForGemini(projectDir);
848
- } else if (tool === 'antigravity') {
849
- results.antigravity = this.applyForAntigravity(projectDir);
850
- }
217
+ if (updated > 0) {
218
+ console.log(`\n✅ Updated ${updated} item(s)`);
219
+ console.log('Restart your shell or run: source ~/.zshrc');
220
+ } else {
221
+ console.log('No files found to update');
851
222
  }
852
223
 
853
- return results;
224
+ return updated > 0;
854
225
  }
855
226
 
856
- /**
857
- * List available MCPs
858
- */
859
- list() {
860
- const registry = this.loadJson(this.registryPath);
861
- if (!registry || !registry.mcpServers) {
862
- console.error('Error: Could not load MCP registry');
863
- return;
864
- }
865
-
866
- const dir = this.findProjectRoot();
867
- const projectConfig = dir ? this.loadJson(path.join(dir, '.claude', 'mcps.json')) : null;
868
- const included = projectConfig?.include || [];
869
-
870
- console.log('\n📚 Available MCPs:\n');
871
- for (const name of Object.keys(registry.mcpServers)) {
872
- const active = included.includes(name) ? ' ✓' : '';
873
- console.log(` • ${name}${active}`);
874
- }
875
- console.log(`\n Total: ${Object.keys(registry.mcpServers).length} in registry`);
876
- if (included.length) {
877
- console.log(` Active: ${included.join(', ')}`);
878
- }
879
- console.log('');
227
+ // Version
228
+ version() {
229
+ console.log(`claude-config v${VERSION}`);
230
+ console.log(`Install: ${this.installDir}`);
231
+ console.log(`Registry: ${this.registryPath}`);
232
+ console.log(`Templates: ${this.templatesDir}`);
880
233
  }
234
+ }
881
235
 
882
- /**
883
- * Initialize project with template
884
- */
885
- init(projectDir = null, templateName = null) {
886
- const dir = projectDir || process.cwd();
887
- const claudeDir = path.join(dir, '.claude');
888
- const configPath = path.join(claudeDir, 'mcps.json');
889
-
890
- // Create .claude directory
891
- if (!fs.existsSync(claudeDir)) {
892
- fs.mkdirSync(claudeDir, { recursive: true });
893
- }
894
-
895
- // Determine MCPs to include
896
- let mcpDefaults = ['github', 'filesystem'];
897
- let templateChain = [];
898
-
899
- if (templateName) {
900
- const templatePath = this.findTemplate(templateName);
901
- if (!templatePath) {
902
- console.error(`Template not found: ${templateName}`);
903
- console.log('Run "claude-config templates" to see available templates.');
904
- return false;
905
- }
906
-
907
- // Resolve full template chain
908
- templateChain = this.resolveTemplateChain(templatePath);
909
-
910
- // Get MCP defaults from the main template
911
- const templateJson = this.loadJson(path.join(templatePath, 'template.json'));
912
- if (templateJson?.mcpDefaults) {
913
- mcpDefaults = templateJson.mcpDefaults;
914
- }
915
-
916
- console.log(`\n🎯 Using template: ${templateName}`);
917
- console.log(` Includes: ${templateChain.map(p => path.basename(p)).join(' → ')}\n`);
918
- }
919
-
920
- // Create or update mcps.json
921
- if (!fs.existsSync(configPath)) {
922
- const template = {
923
- "include": mcpDefaults,
924
- "template": templateName || null,
925
- "mcpServers": {}
926
- };
927
- this.saveJson(configPath, template);
928
- console.log(`✓ Created ${configPath}`);
929
- } else {
930
- console.log(`⏭ ${configPath} already exists`);
931
- }
932
-
933
- // Copy template files
934
- if (templateChain.length > 0) {
935
- console.log('\nCopying template files:');
936
- let totalCopied = 0;
937
- let totalSkipped = 0;
938
-
939
- for (const tplPath of templateChain) {
940
- const { copied, skipped } = this.copyTemplateFiles(tplPath, dir);
941
- totalCopied += copied;
942
- totalSkipped += skipped;
943
- }
944
-
945
- console.log(`\n Total: ${totalCopied} copied, ${totalSkipped} skipped (already exist)`);
946
- }
947
-
948
- // Create .env file
949
- const envPath = path.join(claudeDir, '.env');
950
- if (!fs.existsSync(envPath)) {
951
- fs.writeFileSync(envPath, `# Project secrets (gitignored)
952
- # GITHUB_TOKEN=ghp_xxx
953
- # DATABASE_URL=postgres://...
954
- `);
955
- console.log(`✓ Created ${envPath}`);
956
- }
957
-
958
- // Update .gitignore
959
- const gitignorePath = path.join(dir, '.gitignore');
960
- if (fs.existsSync(gitignorePath)) {
961
- const content = fs.readFileSync(gitignorePath, 'utf8');
962
- if (!content.includes('.claude/.env')) {
963
- fs.appendFileSync(gitignorePath, '\n.claude/.env\n');
964
- console.log('✓ Updated .gitignore');
965
- }
966
- }
967
-
968
- console.log('\n✅ Project initialized!');
969
- console.log('Next steps:');
970
- console.log(' 1. Edit .claude/mcps.json to customize MCPs');
971
- console.log(' 2. Review .claude/rules/ and .claude/commands/');
972
- console.log(' 3. Run: claude-config apply\n');
973
-
974
- return true;
975
- }
976
-
977
- /**
978
- * Apply templates to existing project (add rules/commands without overwriting)
979
- */
980
- applyTemplate(templateName, projectDir = null) {
981
- const dir = projectDir || this.findProjectRoot() || process.cwd();
982
-
983
- if (!templateName) {
984
- console.error('Usage: claude-config apply-template <template-name>');
985
- console.log('Run "claude-config templates" to see available templates.');
986
- return false;
987
- }
988
-
989
- const templatePath = this.findTemplate(templateName);
990
- if (!templatePath) {
991
- console.error(`Template not found: ${templateName}`);
992
- console.log('Run "claude-config templates" to see available templates.');
993
- return false;
994
- }
995
-
996
- // Resolve full template chain
997
- const templateChain = this.resolveTemplateChain(templatePath);
998
-
999
- console.log(`\n🎯 Applying template: ${templateName}`);
1000
- console.log(` Includes: ${templateChain.map(p => path.basename(p)).join(' → ')}\n`);
1001
-
1002
- console.log('Copying template files (won\'t overwrite existing):');
1003
- let totalCopied = 0;
1004
- let totalSkipped = 0;
1005
-
1006
- for (const tplPath of templateChain) {
1007
- const { copied, skipped } = this.copyTemplateFiles(tplPath, dir);
1008
- totalCopied += copied;
1009
- totalSkipped += skipped;
1010
- }
1011
-
1012
- console.log(`\n✅ Applied template: ${totalCopied} files copied, ${totalSkipped} skipped\n`);
1013
-
1014
- // Track applied template in templates.json
1015
- this.trackAppliedTemplate(dir, templateName);
1016
-
1017
- return true;
1018
- }
1019
-
1020
- /**
1021
- * Track an applied template in .claude/templates.json
1022
- * Only one template per project (templates chain internally)
1023
- */
1024
- trackAppliedTemplate(dir, templateName) {
1025
- const claudeDir = path.join(dir, '.claude');
1026
- const templatesPath = path.join(claudeDir, 'templates.json');
1027
-
1028
- // Ensure .claude directory exists
1029
- if (!fs.existsSync(claudeDir)) {
1030
- fs.mkdirSync(claudeDir, { recursive: true });
1031
- }
1032
-
1033
- // Save single template (replaces any previous)
1034
- const data = {
1035
- template: templateName,
1036
- appliedAt: new Date().toISOString()
1037
- };
1038
-
1039
- fs.writeFileSync(templatesPath, JSON.stringify(data, null, 2) + '\n');
1040
- }
1041
-
1042
- /**
1043
- * Get applied template for a directory
1044
- * Returns { template, appliedAt } or null
1045
- */
1046
- getAppliedTemplate(dir) {
1047
- const templatesPath = path.join(dir, '.claude', 'templates.json');
1048
- if (!fs.existsSync(templatesPath)) {
1049
- return null;
1050
- }
1051
- try {
1052
- const data = JSON.parse(fs.readFileSync(templatesPath, 'utf8'));
1053
- if (!data.template) return null;
1054
- return {
1055
- template: data.template,
1056
- appliedAt: data.appliedAt
1057
- };
1058
- } catch (e) {
1059
- return null;
1060
- }
1061
- }
1062
-
1063
- /**
1064
- * Show current project config (including hierarchy)
1065
- */
1066
- show(projectDir = null) {
1067
- const dir = projectDir || this.findProjectRoot() || process.cwd();
1068
-
1069
- // Find all configs in hierarchy
1070
- const configLocations = this.findAllConfigs(dir);
1071
-
1072
- if (configLocations.length === 0) {
1073
- console.log('No .claude/mcps.json found in current directory or parents');
1074
- return;
1075
- }
1076
-
1077
- console.log(`\n📁 Project: ${dir}`);
1078
-
1079
- // Show each config in hierarchy
1080
- if (configLocations.length > 1) {
1081
- console.log('\n📚 Config Hierarchy (root → leaf):');
1082
- }
1083
-
1084
- for (const { dir: d, configPath } of configLocations) {
1085
- const config = this.loadJson(configPath);
1086
- const relPath = d === process.env.HOME ? '~' : path.relative(process.cwd(), d) || '.';
1087
-
1088
- console.log(`\n📄 ${relPath}/.claude/mcps.json:`);
1089
- console.log(JSON.stringify(config, null, 2));
1090
- }
1091
-
1092
- // Show merged result
1093
- if (configLocations.length > 1) {
1094
- const loadedConfigs = configLocations.map(loc => ({
1095
- ...loc,
1096
- config: this.loadJson(loc.configPath)
1097
- }));
1098
- const merged = this.mergeConfigs(loadedConfigs);
1099
- console.log('\n🔀 Merged Config (effective):');
1100
- console.log(JSON.stringify(merged, null, 2));
1101
- }
1102
-
1103
- // Collect rules and commands from all levels in hierarchy
1104
- const allRules = this.collectFilesFromHierarchy(configLocations, 'rules');
1105
- const allCommands = this.collectFilesFromHierarchy(configLocations, 'commands');
1106
-
1107
- if (allRules.length) {
1108
- console.log(`\n📜 Rules (${allRules.length} total):`);
1109
- for (const { file, source } of allRules) {
1110
- const sourceLabel = source === process.env.HOME ? '~' : path.relative(process.cwd(), source) || '.';
1111
- console.log(` • ${file} (${sourceLabel})`);
1112
- }
1113
- }
1114
-
1115
- if (allCommands.length) {
1116
- console.log(`\n⚡ Commands (${allCommands.length} total):`);
1117
- for (const { file, source } of allCommands) {
1118
- const sourceLabel = source === process.env.HOME ? '~' : path.relative(process.cwd(), source) || '.';
1119
- console.log(` • ${file} (${sourceLabel})`);
1120
- }
1121
- }
1122
- console.log('');
1123
- }
1124
-
1125
- // ===========================================================================
1126
- // MCP EDIT COMMANDS
1127
- // ===========================================================================
1128
-
1129
- /**
1130
- * Add MCP(s) to current project
1131
- */
1132
- add(mcpNames) {
1133
- if (!mcpNames || mcpNames.length === 0) {
1134
- console.error('Usage: claude-config add <mcp-name> [mcp-name...]');
1135
- return false;
1136
- }
1137
-
1138
- const configPath = this.getConfigPath();
1139
- let config = this.loadJson(configPath);
1140
-
1141
- if (!config) {
1142
- console.error('No .claude/mcps.json found. Run: claude-config init');
1143
- return false;
1144
- }
1145
-
1146
- const registry = this.loadJson(this.registryPath);
1147
- if (!config.include) config.include = [];
1148
-
1149
- const added = [];
1150
- const notFound = [];
1151
- const alreadyExists = [];
1152
-
1153
- for (const name of mcpNames) {
1154
- if (config.include.includes(name)) {
1155
- alreadyExists.push(name);
1156
- } else if (registry?.mcpServers?.[name]) {
1157
- config.include.push(name);
1158
- added.push(name);
1159
- } else {
1160
- notFound.push(name);
1161
- }
1162
- }
1163
-
1164
- if (added.length) {
1165
- this.saveJson(configPath, config);
1166
- console.log(`✓ Added: ${added.join(', ')}`);
1167
- }
1168
- if (alreadyExists.length) {
1169
- console.log(`Already included: ${alreadyExists.join(', ')}`);
1170
- }
1171
- if (notFound.length) {
1172
- console.log(`Not in registry: ${notFound.join(', ')}`);
1173
- console.log(' (Use "claude-config list" to see available MCPs)');
1174
- }
1175
-
1176
- if (added.length) {
1177
- console.log('\nRun "claude-config apply" to regenerate .mcp.json');
1178
- }
1179
-
1180
- return added.length > 0;
1181
- }
1182
-
1183
- /**
1184
- * Remove MCP(s) from current project
1185
- */
1186
- remove(mcpNames) {
1187
- if (!mcpNames || mcpNames.length === 0) {
1188
- console.error('Usage: claude-config remove <mcp-name> [mcp-name...]');
1189
- return false;
1190
- }
1191
-
1192
- const configPath = this.getConfigPath();
1193
- let config = this.loadJson(configPath);
1194
-
1195
- if (!config) {
1196
- console.error('No .claude/mcps.json found');
1197
- return false;
1198
- }
1199
-
1200
- if (!config.include) config.include = [];
1201
-
1202
- const removed = [];
1203
- const notFound = [];
1204
-
1205
- for (const name of mcpNames) {
1206
- const idx = config.include.indexOf(name);
1207
- if (idx !== -1) {
1208
- config.include.splice(idx, 1);
1209
- removed.push(name);
1210
- } else {
1211
- notFound.push(name);
1212
- }
1213
- }
1214
-
1215
- if (removed.length) {
1216
- this.saveJson(configPath, config);
1217
- console.log(`✓ Removed: ${removed.join(', ')}`);
1218
- console.log('\nRun "claude-config apply" to regenerate .mcp.json');
1219
- }
1220
- if (notFound.length) {
1221
- console.log(`Not in project: ${notFound.join(', ')}`);
1222
- }
1223
-
1224
- return removed.length > 0;
1225
- }
1226
-
1227
- // ===========================================================================
1228
- // REGISTRY COMMANDS
1229
- // ===========================================================================
1230
-
1231
- /**
1232
- * Add MCP to global registry
1233
- */
1234
- registryAdd(name, configJson) {
1235
- if (!name || !configJson) {
1236
- console.error('Usage: claude-config registry-add <name> \'{"command":"...","args":[...]}\'');
1237
- return false;
1238
- }
1239
-
1240
- let mcpConfig;
1241
- try {
1242
- mcpConfig = JSON.parse(configJson);
1243
- } catch (e) {
1244
- console.error('Invalid JSON:', e.message);
1245
- return false;
1246
- }
1247
-
1248
- const registry = this.loadJson(this.registryPath) || { mcpServers: {} };
1249
- registry.mcpServers[name] = mcpConfig;
1250
- this.saveJson(this.registryPath, registry);
1251
-
1252
- console.log(`✓ Added "${name}" to registry`);
1253
- return true;
1254
- }
1255
-
1256
- /**
1257
- * Remove MCP from global registry
1258
- */
1259
- registryRemove(name) {
1260
- if (!name) {
1261
- console.error('Usage: claude-config registry-remove <name>');
1262
- return false;
1263
- }
1264
-
1265
- const registry = this.loadJson(this.registryPath);
1266
- if (!registry?.mcpServers?.[name]) {
1267
- console.error(`"${name}" not found in registry`);
1268
- return false;
1269
- }
1270
-
1271
- delete registry.mcpServers[name];
1272
- this.saveJson(this.registryPath, registry);
1273
-
1274
- console.log(`✓ Removed "${name}" from registry`);
1275
- return true;
1276
- }
1277
-
1278
- // ===========================================================================
1279
- // UPDATE COMMAND
1280
- // ===========================================================================
1281
-
1282
- /**
1283
- * Update claude-config from source
1284
- */
1285
- update(sourcePath) {
1286
- if (!sourcePath) {
1287
- console.error('Usage: claude-config update /path/to/claude-config');
1288
- console.log('\nThis copies updated files from the source to your installation.');
1289
- return false;
1290
- }
1291
-
1292
- if (!fs.existsSync(sourcePath)) {
1293
- console.error(`Source not found: ${sourcePath}`);
1294
- return false;
1295
- }
1296
-
1297
- const files = [
1298
- 'config-loader.js',
1299
- 'shared/mcp-registry.json',
1300
- 'shell/claude-config.zsh'
1301
- ];
1302
-
1303
- let updated = 0;
1304
- for (const file of files) {
1305
- const src = path.join(sourcePath, file);
1306
- const dest = path.join(this.installDir, file);
1307
-
1308
- if (fs.existsSync(src)) {
1309
- const destDir = path.dirname(dest);
1310
- if (!fs.existsSync(destDir)) {
1311
- fs.mkdirSync(destDir, { recursive: true });
1312
- }
1313
- fs.copyFileSync(src, dest);
1314
- console.log(`✓ Updated ${file}`);
1315
- updated++;
1316
- }
1317
- }
1318
-
1319
- // Copy templates directory
1320
- const srcTemplates = path.join(sourcePath, 'templates');
1321
- const destTemplates = path.join(this.installDir, 'templates');
1322
- if (fs.existsSync(srcTemplates)) {
1323
- this.copyDirRecursive(srcTemplates, destTemplates);
1324
- console.log(`✓ Updated templates/`);
1325
- updated++;
1326
- }
1327
-
1328
- if (updated > 0) {
1329
- console.log(`\n✅ Updated ${updated} item(s)`);
1330
- console.log('Restart your shell or run: source ~/.zshrc');
1331
- } else {
1332
- console.log('No files found to update');
1333
- }
1334
-
1335
- return updated > 0;
1336
- }
1337
-
1338
- /**
1339
- * Recursively copy directory
1340
- */
1341
- copyDirRecursive(src, dest) {
1342
- if (!fs.existsSync(dest)) {
1343
- fs.mkdirSync(dest, { recursive: true });
1344
- }
1345
-
1346
- for (const item of fs.readdirSync(src)) {
1347
- const srcPath = path.join(src, item);
1348
- const destPath = path.join(dest, item);
1349
-
1350
- if (fs.statSync(srcPath).isDirectory()) {
1351
- this.copyDirRecursive(srcPath, destPath);
1352
- } else {
1353
- fs.copyFileSync(srcPath, destPath);
1354
- }
1355
- }
1356
- }
1357
-
1358
- /**
1359
- * Show version
1360
- */
1361
- version() {
1362
- console.log(`claude-config v${VERSION}`);
1363
- console.log(`Install: ${this.installDir}`);
1364
- console.log(`Registry: ${this.registryPath}`);
1365
- console.log(`Templates: ${this.templatesDir}`);
1366
- }
1367
-
1368
- // ===========================================================================
1369
- // MEMORY COMMANDS
1370
- // ===========================================================================
1371
-
1372
- /**
1373
- * Show memory status and contents
1374
- */
1375
- memoryList(projectDir = process.cwd()) {
1376
- const homeDir = process.env.HOME || '';
1377
- const globalMemoryDir = path.join(homeDir, '.claude', 'memory');
1378
- const projectMemoryDir = path.join(projectDir, '.claude', 'memory');
1379
-
1380
- console.log('\n📝 Memory System\n');
1381
-
1382
- // Global memory
1383
- console.log('Global (~/.claude/memory/):');
1384
- if (fs.existsSync(globalMemoryDir)) {
1385
- const files = ['preferences.md', 'corrections.md', 'facts.md'];
1386
- for (const file of files) {
1387
- const filePath = path.join(globalMemoryDir, file);
1388
- if (fs.existsSync(filePath)) {
1389
- const content = fs.readFileSync(filePath, 'utf8');
1390
- const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#')).length;
1391
- console.log(` ✓ ${file} (${lines} entries)`);
1392
- } else {
1393
- console.log(` ○ ${file} (not created)`);
1394
- }
1395
- }
1396
- } else {
1397
- console.log(' Not initialized');
1398
- }
1399
-
1400
- // Project memory
1401
- console.log(`\nProject (${projectDir}/.claude/memory/):`);
1402
- if (fs.existsSync(projectMemoryDir)) {
1403
- const files = ['context.md', 'patterns.md', 'decisions.md', 'issues.md', 'history.md'];
1404
- for (const file of files) {
1405
- const filePath = path.join(projectMemoryDir, file);
1406
- if (fs.existsSync(filePath)) {
1407
- const content = fs.readFileSync(filePath, 'utf8');
1408
- const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#')).length;
1409
- console.log(` ✓ ${file} (${lines} entries)`);
1410
- } else {
1411
- console.log(` ○ ${file} (not created)`);
1412
- }
1413
- }
1414
- } else {
1415
- console.log(' Not initialized. Run: claude-config memory init');
1416
- }
1417
- console.log();
1418
- }
1419
-
1420
- /**
1421
- * Initialize project memory
1422
- */
1423
- memoryInit(projectDir = process.cwd()) {
1424
- const memoryDir = path.join(projectDir, '.claude', 'memory');
1425
-
1426
- if (fs.existsSync(memoryDir)) {
1427
- console.log('Project memory already initialized at', memoryDir);
1428
- return;
1429
- }
1430
-
1431
- fs.mkdirSync(memoryDir, { recursive: true });
1432
-
1433
- const files = {
1434
- 'context.md': '# Project Context\n\n<!-- Project overview and key information -->\n',
1435
- 'patterns.md': '# Code Patterns\n\n<!-- Established patterns in this codebase -->\n',
1436
- 'decisions.md': '# Architecture Decisions\n\n<!-- Key decisions and their rationale -->\n',
1437
- 'issues.md': '# Known Issues\n\n<!-- Current issues and workarounds -->\n',
1438
- 'history.md': '# Session History\n\n<!-- Notable changes and milestones -->\n'
1439
- };
1440
-
1441
- for (const [file, content] of Object.entries(files)) {
1442
- fs.writeFileSync(path.join(memoryDir, file), content);
1443
- }
1444
-
1445
- console.log(`✓ Initialized project memory at ${memoryDir}`);
1446
- console.log('\nCreated:');
1447
- for (const file of Object.keys(files)) {
1448
- console.log(` ${file}`);
1449
- }
1450
- }
1451
-
1452
- /**
1453
- * Add entry to memory
1454
- */
1455
- memoryAdd(type, content, projectDir = process.cwd()) {
1456
- if (!type || !content) {
1457
- console.error('Usage: claude-config memory add <type> "<content>"');
1458
- console.log('\nTypes:');
1459
- console.log(' Global: preference, correction, fact');
1460
- console.log(' Project: context, pattern, decision, issue, history');
1461
- return;
1462
- }
1463
-
1464
- const homeDir = process.env.HOME || '';
1465
- const timestamp = new Date().toISOString().split('T')[0];
1466
-
1467
- // Map type to file
1468
- const typeMap = {
1469
- // Global
1470
- preference: { dir: path.join(homeDir, '.claude', 'memory'), file: 'preferences.md' },
1471
- correction: { dir: path.join(homeDir, '.claude', 'memory'), file: 'corrections.md' },
1472
- fact: { dir: path.join(homeDir, '.claude', 'memory'), file: 'facts.md' },
1473
- // Project
1474
- context: { dir: path.join(projectDir, '.claude', 'memory'), file: 'context.md' },
1475
- pattern: { dir: path.join(projectDir, '.claude', 'memory'), file: 'patterns.md' },
1476
- decision: { dir: path.join(projectDir, '.claude', 'memory'), file: 'decisions.md' },
1477
- issue: { dir: path.join(projectDir, '.claude', 'memory'), file: 'issues.md' },
1478
- history: { dir: path.join(projectDir, '.claude', 'memory'), file: 'history.md' }
1479
- };
1480
-
1481
- const target = typeMap[type];
1482
- if (!target) {
1483
- console.error(`Unknown type: ${type}`);
1484
- console.log('Valid types: preference, correction, fact, context, pattern, decision, issue, history');
1485
- return;
1486
- }
1487
-
1488
- // Ensure directory exists
1489
- if (!fs.existsSync(target.dir)) {
1490
- fs.mkdirSync(target.dir, { recursive: true });
1491
- }
1492
-
1493
- const filePath = path.join(target.dir, target.file);
1494
-
1495
- // Create file with header if it doesn't exist
1496
- if (!fs.existsSync(filePath)) {
1497
- const headers = {
1498
- 'preferences.md': '# Preferences\n',
1499
- 'corrections.md': '# Corrections\n',
1500
- 'facts.md': '# Facts\n',
1501
- 'context.md': '# Project Context\n',
1502
- 'patterns.md': '# Code Patterns\n',
1503
- 'decisions.md': '# Architecture Decisions\n',
1504
- 'issues.md': '# Known Issues\n',
1505
- 'history.md': '# Session History\n'
1506
- };
1507
- fs.writeFileSync(filePath, headers[target.file] || '');
1508
- }
1509
-
1510
- // Append entry
1511
- const entry = `\n- [${timestamp}] ${content}\n`;
1512
- fs.appendFileSync(filePath, entry);
1513
-
1514
- console.log(`✓ Added ${type} to ${target.file}`);
1515
- }
1516
-
1517
- /**
1518
- * Search memory files
1519
- */
1520
- memorySearch(query, projectDir = process.cwd()) {
1521
- if (!query) {
1522
- console.error('Usage: claude-config memory search <query>');
1523
- return;
1524
- }
1525
-
1526
- const homeDir = process.env.HOME || '';
1527
- const searchDirs = [
1528
- { label: 'Global', dir: path.join(homeDir, '.claude', 'memory') },
1529
- { label: 'Project', dir: path.join(projectDir, '.claude', 'memory') }
1530
- ];
1531
-
1532
- const results = [];
1533
- const queryLower = query.toLowerCase();
1534
-
1535
- for (const { label, dir } of searchDirs) {
1536
- if (!fs.existsSync(dir)) continue;
1537
-
1538
- for (const file of fs.readdirSync(dir)) {
1539
- if (!file.endsWith('.md')) continue;
1540
- const filePath = path.join(dir, file);
1541
- const content = fs.readFileSync(filePath, 'utf8');
1542
- const lines = content.split('\n');
1543
-
1544
- for (let i = 0; i < lines.length; i++) {
1545
- if (lines[i].toLowerCase().includes(queryLower)) {
1546
- results.push({
1547
- location: `${label}/${file}`,
1548
- line: i + 1,
1549
- content: lines[i].trim()
1550
- });
1551
- }
1552
- }
1553
- }
1554
- }
1555
-
1556
- if (results.length === 0) {
1557
- console.log(`No matches found for "${query}"`);
1558
- return;
1559
- }
1560
-
1561
- console.log(`\n🔍 Found ${results.length} match(es) for "${query}":\n`);
1562
- for (const r of results) {
1563
- console.log(` ${r.location}:${r.line}`);
1564
- console.log(` ${r.content}\n`);
1565
- }
1566
- }
1567
-
1568
- // ===========================================================================
1569
- // ENV COMMANDS
1570
- // ===========================================================================
1571
-
1572
- /**
1573
- * List environment variables
1574
- */
1575
- envList(projectDir = process.cwd()) {
1576
- const envPath = path.join(projectDir, '.claude', '.env');
1577
-
1578
- console.log(`\n🔐 Environment Variables (${projectDir}/.claude/.env)\n`);
1579
-
1580
- if (!fs.existsSync(envPath)) {
1581
- console.log(' No .env file found.');
1582
- console.log(' Create with: claude-config env set <KEY> <value>\n');
1583
- return;
1584
- }
1585
-
1586
- const content = fs.readFileSync(envPath, 'utf8');
1587
- const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
1588
-
1589
- if (lines.length === 0) {
1590
- console.log(' No variables set.\n');
1591
- return;
1592
- }
1593
-
1594
- for (const line of lines) {
1595
- const [key] = line.split('=');
1596
- if (key) {
1597
- console.log(` ${key}=****`);
1598
- }
1599
- }
1600
- console.log(`\n Total: ${lines.length} variable(s)\n`);
1601
- }
1602
-
1603
- /**
1604
- * Set environment variable
1605
- */
1606
- envSet(key, value, projectDir = process.cwd()) {
1607
- if (!key || value === undefined) {
1608
- console.error('Usage: claude-config env set <KEY> <value>');
1609
- return;
1610
- }
1611
-
1612
- const claudeDir = path.join(projectDir, '.claude');
1613
- const envPath = path.join(claudeDir, '.env');
1614
-
1615
- // Ensure .claude directory exists
1616
- if (!fs.existsSync(claudeDir)) {
1617
- fs.mkdirSync(claudeDir, { recursive: true });
1618
- }
1619
-
1620
- // Read existing content
1621
- let lines = [];
1622
- if (fs.existsSync(envPath)) {
1623
- lines = fs.readFileSync(envPath, 'utf8').split('\n');
1624
- }
1625
-
1626
- // Update or add the variable
1627
- const keyUpper = key.toUpperCase();
1628
- let found = false;
1629
- lines = lines.map(line => {
1630
- if (line.startsWith(`${keyUpper}=`)) {
1631
- found = true;
1632
- return `${keyUpper}=${value}`;
1633
- }
1634
- return line;
1635
- });
1636
-
1637
- if (!found) {
1638
- lines.push(`${keyUpper}=${value}`);
1639
- }
1640
-
1641
- // Write back
1642
- fs.writeFileSync(envPath, lines.filter(l => l.trim()).join('\n') + '\n');
1643
-
1644
- console.log(`✓ Set ${keyUpper} in .claude/.env`);
1645
- }
1646
-
1647
- /**
1648
- * Unset environment variable
1649
- */
1650
- envUnset(key, projectDir = process.cwd()) {
1651
- if (!key) {
1652
- console.error('Usage: claude-config env unset <KEY>');
1653
- return;
1654
- }
1655
-
1656
- const envPath = path.join(projectDir, '.claude', '.env');
1657
-
1658
- if (!fs.existsSync(envPath)) {
1659
- console.log('No .env file found.');
1660
- return;
1661
- }
1662
-
1663
- const keyUpper = key.toUpperCase();
1664
- let lines = fs.readFileSync(envPath, 'utf8').split('\n');
1665
- const originalLength = lines.length;
1666
-
1667
- lines = lines.filter(line => !line.startsWith(`${keyUpper}=`));
1668
-
1669
- if (lines.length === originalLength) {
1670
- console.log(`Variable ${keyUpper} not found.`);
1671
- return;
1672
- }
1673
-
1674
- fs.writeFileSync(envPath, lines.filter(l => l.trim()).join('\n') + '\n');
1675
- console.log(`✓ Removed ${keyUpper} from .claude/.env`);
1676
- }
1677
-
1678
- // ===========================================================================
1679
- // PROJECT REGISTRY (for UI project switching)
1680
- // ===========================================================================
1681
-
1682
- /**
1683
- * Get projects registry path
1684
- */
1685
- getProjectsRegistryPath() {
1686
- return path.join(this.installDir, 'projects.json');
1687
- }
1688
-
1689
- /**
1690
- * Load projects registry
1691
- */
1692
- loadProjectsRegistry() {
1693
- const registryPath = this.getProjectsRegistryPath();
1694
- if (fs.existsSync(registryPath)) {
1695
- try {
1696
- return JSON.parse(fs.readFileSync(registryPath, 'utf8'));
1697
- } catch (e) {
1698
- return { projects: [], activeProjectId: null };
1699
- }
1700
- }
1701
- return { projects: [], activeProjectId: null };
1702
- }
1703
-
1704
- /**
1705
- * Save projects registry
1706
- */
1707
- saveProjectsRegistry(registry) {
1708
- const registryPath = this.getProjectsRegistryPath();
1709
- fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
1710
- }
1711
-
1712
- /**
1713
- * List registered projects
1714
- */
1715
- projectList() {
1716
- const registry = this.loadProjectsRegistry();
1717
-
1718
- if (registry.projects.length === 0) {
1719
- console.log('\nNo projects registered.');
1720
- console.log('Add one with: claude-config project add [path]\n');
1721
- return;
1722
- }
1723
-
1724
- console.log('\n📁 Registered Projects:\n');
1725
- for (const p of registry.projects) {
1726
- const active = p.id === registry.activeProjectId ? '→ ' : ' ';
1727
- const exists = fs.existsSync(p.path) ? '' : ' (not found)';
1728
- console.log(`${active}${p.name}${exists}`);
1729
- console.log(` ${p.path}`);
1730
- }
1731
- console.log('');
1732
- }
1733
-
1734
- /**
1735
- * Add project to registry
1736
- */
1737
- projectAdd(projectPath = process.cwd(), name = null) {
1738
- const absPath = path.resolve(projectPath.replace(/^~/, process.env.HOME || ''));
1739
-
1740
- if (!fs.existsSync(absPath)) {
1741
- console.error(`Path not found: ${absPath}`);
1742
- return false;
1743
- }
1744
-
1745
- const registry = this.loadProjectsRegistry();
1746
-
1747
- // Check for duplicate
1748
- if (registry.projects.some(p => p.path === absPath)) {
1749
- console.log(`Already registered: ${absPath}`);
1750
- return false;
1751
- }
1752
-
1753
- const project = {
1754
- id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
1755
- name: name || path.basename(absPath),
1756
- path: absPath,
1757
- addedAt: new Date().toISOString(),
1758
- lastOpened: null
1759
- };
1760
-
1761
- registry.projects.push(project);
1762
-
1763
- // If first project, make it active
1764
- if (!registry.activeProjectId) {
1765
- registry.activeProjectId = project.id;
1766
- }
1767
-
1768
- this.saveProjectsRegistry(registry);
1769
- console.log(`✓ Added project: ${project.name}`);
1770
- console.log(` ${absPath}`);
1771
- return true;
1772
- }
1773
-
1774
- /**
1775
- * Remove project from registry
1776
- */
1777
- projectRemove(nameOrPath) {
1778
- if (!nameOrPath) {
1779
- console.error('Usage: claude-config project remove <name|path>');
1780
- return false;
1781
- }
1782
-
1783
- const registry = this.loadProjectsRegistry();
1784
- const absPath = path.resolve(nameOrPath.replace(/^~/, process.env.HOME || ''));
1785
-
1786
- const idx = registry.projects.findIndex(
1787
- p => p.name === nameOrPath || p.path === absPath
1788
- );
1789
-
1790
- if (idx === -1) {
1791
- console.error(`Project not found: ${nameOrPath}`);
1792
- return false;
1793
- }
1794
-
1795
- const removed = registry.projects.splice(idx, 1)[0];
1796
-
1797
- // If removed active project, select first remaining
1798
- if (registry.activeProjectId === removed.id) {
1799
- registry.activeProjectId = registry.projects[0]?.id || null;
1800
- }
1801
-
1802
- this.saveProjectsRegistry(registry);
1803
- console.log(`✓ Removed project: ${removed.name}`);
1804
- return true;
1805
- }
1806
-
1807
- // ===========================================================================
1808
- // WORKSTREAMS
1809
- // ===========================================================================
1810
-
1811
- /**
1812
- * Get workstreams file path
1813
- */
1814
- getWorkstreamsPath() {
1815
- return path.join(this.installDir, 'workstreams.json');
1816
- }
1817
-
1818
- /**
1819
- * Load workstreams
1820
- */
1821
- loadWorkstreams() {
1822
- const wsPath = this.getWorkstreamsPath();
1823
- if (fs.existsSync(wsPath)) {
1824
- try {
1825
- return JSON.parse(fs.readFileSync(wsPath, 'utf8'));
1826
- } catch (e) {
1827
- return { workstreams: [], activeId: null, lastUsedByProject: {} };
1828
- }
1829
- }
1830
- return { workstreams: [], activeId: null, lastUsedByProject: {} };
1831
- }
1832
-
1833
- /**
1834
- * Save workstreams
1835
- */
1836
- saveWorkstreams(data) {
1837
- const wsPath = this.getWorkstreamsPath();
1838
- const dir = path.dirname(wsPath);
1839
- if (!fs.existsSync(dir)) {
1840
- fs.mkdirSync(dir, { recursive: true });
1841
- }
1842
- fs.writeFileSync(wsPath, JSON.stringify(data, null, 2) + '\n');
1843
- }
1844
-
1845
- /**
1846
- * List all workstreams
1847
- */
1848
- workstreamList() {
1849
- const data = this.loadWorkstreams();
1850
-
1851
- if (data.workstreams.length === 0) {
1852
- console.log('\nNo workstreams defined.');
1853
- console.log('Create one with: claude-config workstream create "Name"\n');
1854
- return data.workstreams;
1855
- }
1856
-
1857
- console.log('\n📋 Workstreams:\n');
1858
- for (const ws of data.workstreams) {
1859
- const active = ws.id === data.activeId ? '● ' : '○ ';
1860
- console.log(`${active}${ws.name}`);
1861
- if (ws.projects && ws.projects.length > 0) {
1862
- console.log(` Projects: ${ws.projects.map(p => path.basename(p)).join(', ')}`);
1863
- }
1864
- if (ws.rules) {
1865
- const preview = ws.rules.substring(0, 60).replace(/\n/g, ' ');
1866
- console.log(` Rules: ${preview}${ws.rules.length > 60 ? '...' : ''}`);
1867
- }
1868
- }
1869
- console.log('');
1870
- return data.workstreams;
1871
- }
1872
-
1873
- /**
1874
- * Create a new workstream
1875
- */
1876
- workstreamCreate(name, projects = [], rules = '') {
1877
- if (!name) {
1878
- console.error('Usage: claude-config workstream create "Name"');
1879
- return null;
1880
- }
1881
-
1882
- const data = this.loadWorkstreams();
1883
-
1884
- // Check for duplicate name
1885
- if (data.workstreams.some(ws => ws.name.toLowerCase() === name.toLowerCase())) {
1886
- console.error(`Workstream "${name}" already exists`);
1887
- return null;
1888
- }
1889
-
1890
- const workstream = {
1891
- id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
1892
- name,
1893
- projects: projects.map(p => path.resolve(p.replace(/^~/, process.env.HOME || ''))),
1894
- rules: rules || '',
1895
- createdAt: new Date().toISOString(),
1896
- updatedAt: new Date().toISOString()
1897
- };
1898
-
1899
- data.workstreams.push(workstream);
1900
-
1901
- // If first workstream, make it active
1902
- if (!data.activeId) {
1903
- data.activeId = workstream.id;
1904
- }
1905
-
1906
- this.saveWorkstreams(data);
1907
- console.log(`✓ Created workstream: ${name}`);
1908
- return workstream;
1909
- }
1910
-
1911
- /**
1912
- * Update a workstream
1913
- */
1914
- workstreamUpdate(idOrName, updates) {
1915
- const data = this.loadWorkstreams();
1916
- const ws = data.workstreams.find(
1917
- w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
1918
- );
1919
-
1920
- if (!ws) {
1921
- console.error(`Workstream not found: ${idOrName}`);
1922
- return null;
1923
- }
1924
-
1925
- if (updates.name !== undefined) ws.name = updates.name;
1926
- if (updates.projects !== undefined) {
1927
- ws.projects = updates.projects.map(p =>
1928
- path.resolve(p.replace(/^~/, process.env.HOME || ''))
1929
- );
1930
- }
1931
- if (updates.rules !== undefined) ws.rules = updates.rules;
1932
- ws.updatedAt = new Date().toISOString();
1933
-
1934
- this.saveWorkstreams(data);
1935
- console.log(`✓ Updated workstream: ${ws.name}`);
1936
- return ws;
1937
- }
1938
-
1939
- /**
1940
- * Delete a workstream
1941
- */
1942
- workstreamDelete(idOrName) {
1943
- const data = this.loadWorkstreams();
1944
- const idx = data.workstreams.findIndex(
1945
- w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
1946
- );
1947
-
1948
- if (idx === -1) {
1949
- console.error(`Workstream not found: ${idOrName}`);
1950
- return false;
1951
- }
1952
-
1953
- const removed = data.workstreams.splice(idx, 1)[0];
1954
-
1955
- // If removed active workstream, select first remaining
1956
- if (data.activeId === removed.id) {
1957
- data.activeId = data.workstreams[0]?.id || null;
1958
- }
1959
-
1960
- this.saveWorkstreams(data);
1961
- console.log(`✓ Deleted workstream: ${removed.name}`);
1962
- return true;
1963
- }
1964
-
1965
- /**
1966
- * Set active workstream
1967
- */
1968
- workstreamUse(idOrName) {
1969
- const data = this.loadWorkstreams();
1970
-
1971
- if (!idOrName) {
1972
- // Show current active
1973
- const active = data.workstreams.find(w => w.id === data.activeId);
1974
- if (active) {
1975
- console.log(`Active workstream: ${active.name}`);
1976
- } else {
1977
- console.log('No active workstream');
1978
- }
1979
- return active || null;
1980
- }
1981
-
1982
- const ws = data.workstreams.find(
1983
- w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
1984
- );
1985
-
1986
- if (!ws) {
1987
- console.error(`Workstream not found: ${idOrName}`);
1988
- return null;
1989
- }
1990
-
1991
- data.activeId = ws.id;
1992
- this.saveWorkstreams(data);
1993
- console.log(`✓ Switched to workstream: ${ws.name}`);
1994
- return ws;
1995
- }
1996
-
1997
- /**
1998
- * Get active workstream
1999
- */
2000
- workstreamActive() {
2001
- const data = this.loadWorkstreams();
2002
- return data.workstreams.find(w => w.id === data.activeId) || null;
2003
- }
2004
-
2005
- /**
2006
- * Add project to workstream
2007
- */
2008
- workstreamAddProject(idOrName, projectPath) {
2009
- const data = this.loadWorkstreams();
2010
- const ws = data.workstreams.find(
2011
- w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
2012
- );
2013
-
2014
- if (!ws) {
2015
- console.error(`Workstream not found: ${idOrName}`);
2016
- return null;
2017
- }
2018
-
2019
- const absPath = path.resolve(projectPath.replace(/^~/, process.env.HOME || ''));
2020
-
2021
- if (!ws.projects.includes(absPath)) {
2022
- ws.projects.push(absPath);
2023
- ws.updatedAt = new Date().toISOString();
2024
- this.saveWorkstreams(data);
2025
- console.log(`✓ Added ${path.basename(absPath)} to ${ws.name}`);
2026
- } else {
2027
- console.log(`Project already in workstream: ${path.basename(absPath)}`);
2028
- }
2029
-
2030
- return ws;
2031
- }
2032
-
2033
- /**
2034
- * Remove project from workstream
2035
- */
2036
- workstreamRemoveProject(idOrName, projectPath) {
2037
- const data = this.loadWorkstreams();
2038
- const ws = data.workstreams.find(
2039
- w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
2040
- );
2041
-
2042
- if (!ws) {
2043
- console.error(`Workstream not found: ${idOrName}`);
2044
- return null;
2045
- }
2046
-
2047
- const absPath = path.resolve(projectPath.replace(/^~/, process.env.HOME || ''));
2048
- const idx = ws.projects.indexOf(absPath);
2049
-
2050
- if (idx !== -1) {
2051
- ws.projects.splice(idx, 1);
2052
- ws.updatedAt = new Date().toISOString();
2053
- this.saveWorkstreams(data);
2054
- console.log(`✓ Removed ${path.basename(absPath)} from ${ws.name}`);
2055
- } else {
2056
- console.log(`Project not in workstream: ${path.basename(absPath)}`);
2057
- }
2058
-
2059
- return ws;
2060
- }
2061
-
2062
- /**
2063
- * Inject active workstream rules into Claude context
2064
- * Called by pre-prompt hook
2065
- */
2066
- workstreamInject(silent = false) {
2067
- const active = this.workstreamActive();
2068
-
2069
- if (!active) {
2070
- if (!silent) console.log('No active workstream');
2071
- return null;
2072
- }
2073
-
2074
- if (!active.rules || active.rules.trim() === '') {
2075
- if (!silent) console.log(`Workstream "${active.name}" has no rules defined`);
2076
- return null;
2077
- }
2078
-
2079
- // Output rules to stdout for hook to capture
2080
- const header = `## Active Workstream: ${active.name}\n\n`;
2081
- const output = header + active.rules;
2082
-
2083
- if (!silent) {
2084
- console.log(output);
2085
- }
2086
-
2087
- return output;
2088
- }
2089
-
2090
- /**
2091
- * Detect workstream from current directory
2092
- */
2093
- workstreamDetect(dir = process.cwd()) {
2094
- const data = this.loadWorkstreams();
2095
- const absDir = path.resolve(dir.replace(/^~/, process.env.HOME || ''));
2096
-
2097
- // Find workstreams that contain this directory or a parent
2098
- const matches = data.workstreams.filter(ws =>
2099
- ws.projects.some(p => absDir.startsWith(p) || p.startsWith(absDir))
2100
- );
2101
-
2102
- if (matches.length === 0) {
2103
- return null;
2104
- }
2105
-
2106
- if (matches.length === 1) {
2107
- return matches[0];
2108
- }
2109
-
2110
- // Multiple matches - check lastUsedByProject
2111
- if (data.lastUsedByProject && data.lastUsedByProject[absDir]) {
2112
- const lastUsed = matches.find(ws => ws.id === data.lastUsedByProject[absDir]);
2113
- if (lastUsed) return lastUsed;
2114
- }
2115
-
2116
- // Return most recently updated
2117
- return matches.sort((a, b) =>
2118
- new Date(b.updatedAt) - new Date(a.updatedAt)
2119
- )[0];
2120
- }
2121
-
2122
- /**
2123
- * Get workstream by ID
2124
- */
2125
- workstreamGet(id) {
2126
- const data = this.loadWorkstreams();
2127
- return data.workstreams.find(w => w.id === id) || null;
2128
- }
2129
-
2130
- // ===========================================================================
2131
- // ACTIVITY TRACKING
2132
- // ===========================================================================
2133
-
2134
- /**
2135
- * Get activity file path
2136
- */
2137
- getActivityPath() {
2138
- return path.join(this.installDir, 'activity.json');
2139
- }
2140
-
2141
- /**
2142
- * Load activity data
2143
- */
2144
- loadActivity() {
2145
- const activityPath = this.getActivityPath();
2146
- if (fs.existsSync(activityPath)) {
2147
- try {
2148
- return JSON.parse(fs.readFileSync(activityPath, 'utf8'));
2149
- } catch (e) {
2150
- return this.getDefaultActivity();
2151
- }
2152
- }
2153
- return this.getDefaultActivity();
2154
- }
2155
-
2156
- /**
2157
- * Get default activity structure
2158
- */
2159
- getDefaultActivity() {
2160
- return {
2161
- sessions: [],
2162
- projectStats: {},
2163
- coActivity: {},
2164
- lastUpdated: null
2165
- };
2166
- }
2167
-
2168
- /**
2169
- * Save activity data
2170
- */
2171
- saveActivity(data) {
2172
- const activityPath = this.getActivityPath();
2173
- const dir = path.dirname(activityPath);
2174
- if (!fs.existsSync(dir)) {
2175
- fs.mkdirSync(dir, { recursive: true });
2176
- }
2177
- data.lastUpdated = new Date().toISOString();
2178
- fs.writeFileSync(activityPath, JSON.stringify(data, null, 2) + '\n');
2179
- }
2180
-
2181
- /**
2182
- * Log activity from a Claude session
2183
- */
2184
- activityLog(files, sessionId = null) {
2185
- const data = this.loadActivity();
2186
- const now = new Date().toISOString();
2187
-
2188
- if (!sessionId) {
2189
- sessionId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
2190
- }
2191
-
2192
- let session = data.sessions.find(s => s.id === sessionId);
2193
- if (!session) {
2194
- session = { id: sessionId, startedAt: now, files: [], projects: [] };
2195
- data.sessions.push(session);
2196
- }
2197
-
2198
- const projectsInSession = new Set(session.projects);
2199
-
2200
- for (const file of files) {
2201
- // Handle both string paths and {path, action} objects
2202
- const rawPath = typeof file === 'string' ? file : file.path;
2203
- if (!rawPath) continue;
2204
- const filePath = path.resolve(rawPath.replace(/^~/, process.env.HOME || ''));
2205
- const action = typeof file === 'object' ? (file.action || 'access') : 'access';
2206
-
2207
- session.files.push({ path: filePath, action, timestamp: now });
2208
-
2209
- const projectPath = this.detectProjectRoot(filePath);
2210
- if (projectPath) {
2211
- projectsInSession.add(projectPath);
2212
-
2213
- if (!data.projectStats[projectPath]) {
2214
- data.projectStats[projectPath] = { fileCount: 0, lastActive: now, sessionCount: 0 };
2215
- }
2216
- data.projectStats[projectPath].fileCount++;
2217
- data.projectStats[projectPath].lastActive = now;
2218
- }
2219
- }
2220
-
2221
- session.projects = Array.from(projectsInSession);
2222
-
2223
- // Update co-activity
2224
- const projects = session.projects;
2225
- for (let i = 0; i < projects.length; i++) {
2226
- for (let j = i + 1; j < projects.length; j++) {
2227
- const p1 = projects[i], p2 = projects[j];
2228
- if (!data.coActivity[p1]) data.coActivity[p1] = {};
2229
- if (!data.coActivity[p2]) data.coActivity[p2] = {};
2230
- data.coActivity[p1][p2] = (data.coActivity[p1][p2] || 0) + 1;
2231
- data.coActivity[p2][p1] = (data.coActivity[p2][p1] || 0) + 1;
2232
- }
2233
- }
2234
-
2235
- if (data.sessions.length > 100) {
2236
- data.sessions = data.sessions.slice(-100);
2237
- }
2238
-
2239
- this.saveActivity(data);
2240
- return { sessionId, filesLogged: files.length, projects: session.projects };
2241
- }
2242
-
2243
- /**
2244
- * Detect project root by finding .git or .claude folder
2245
- */
2246
- detectProjectRoot(filePath) {
2247
- let dir = path.dirname(filePath);
2248
- const home = process.env.HOME || '';
2249
-
2250
- while (dir && dir !== '/' && dir !== home) {
2251
- if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, '.claude'))) {
2252
- return dir;
2253
- }
2254
- dir = path.dirname(dir);
2255
- }
2256
- return null;
2257
- }
2258
-
2259
- /**
2260
- * Get activity summary for UI
2261
- */
2262
- activitySummary() {
2263
- const data = this.loadActivity();
2264
- const now = new Date();
2265
- const oneDayAgo = new Date(now - 24 * 60 * 60 * 1000);
2266
-
2267
- const recentSessions = data.sessions.filter(s => new Date(s.startedAt) > oneDayAgo);
2268
-
2269
- const projectActivity = Object.entries(data.projectStats)
2270
- .map(([projectPath, stats]) => ({
2271
- path: projectPath,
2272
- name: path.basename(projectPath),
2273
- ...stats,
2274
- isRecent: new Date(stats.lastActive) > oneDayAgo
2275
- }))
2276
- .sort((a, b) => new Date(b.lastActive) - new Date(a.lastActive));
2277
-
2278
- const coActiveProjects = [];
2279
- for (const [project, coProjects] of Object.entries(data.coActivity)) {
2280
- for (const [otherProject, count] of Object.entries(coProjects)) {
2281
- if (count >= 2 && project < otherProject) {
2282
- coActiveProjects.push({
2283
- projects: [project, otherProject],
2284
- names: [path.basename(project), path.basename(otherProject)],
2285
- count
2286
- });
2287
- }
2288
- }
2289
- }
2290
- coActiveProjects.sort((a, b) => b.count - a.count);
2291
-
2292
- // Calculate total files across all sessions
2293
- const totalFiles = data.sessions.reduce((sum, s) => sum + (s.files?.length || 0), 0);
2294
-
2295
- return {
2296
- totalSessions: data.sessions.length,
2297
- recentSessions: recentSessions.length,
2298
- totalFiles,
2299
- projectCount: Object.keys(data.projectStats).length,
2300
- topProjects: projectActivity.slice(0, 10),
2301
- projectActivity: projectActivity.slice(0, 20),
2302
- coActiveProjects: coActiveProjects.slice(0, 10),
2303
- lastUpdated: data.lastUpdated
2304
- };
2305
- }
2306
-
2307
- /**
2308
- * Suggest workstreams based on activity patterns
2309
- */
2310
- activitySuggestWorkstreams() {
2311
- const data = this.loadActivity();
2312
- const workstreams = this.loadWorkstreams();
2313
- const suggestions = [];
2314
-
2315
- const coGroups = new Map();
2316
-
2317
- for (const session of data.sessions) {
2318
- if (session.projects.length >= 2) {
2319
- const key = session.projects.sort().join('|');
2320
- coGroups.set(key, (coGroups.get(key) || 0) + 1);
2321
- }
2322
- }
2323
-
2324
- for (const [key, count] of coGroups) {
2325
- if (count >= 3) {
2326
- const projects = key.split('|');
2327
- const existingWs = workstreams.workstreams.find(ws =>
2328
- projects.every(p => ws.projects.includes(p))
2329
- );
2330
-
2331
- if (!existingWs) {
2332
- // Calculate co-activity score as percentage of total sessions
2333
- const totalSessions = data.sessions.length;
2334
- const coActivityScore = totalSessions > 0 ? Math.round((count / totalSessions) * 100) : 0;
2335
-
2336
- suggestions.push({
2337
- projects,
2338
- name: this.generateWorkstreamName(projects),
2339
- names: projects.map(p => path.basename(p)),
2340
- sessionCount: count,
2341
- coActivityScore: Math.min(coActivityScore, 100),
2342
- });
2343
- }
2344
- }
2345
- }
2346
-
2347
- suggestions.sort((a, b) => b.sessionCount - a.sessionCount);
2348
- return suggestions.slice(0, 5);
2349
- }
2350
-
2351
- /**
2352
- * Generate a workstream name from project names
2353
- */
2354
- generateWorkstreamName(projects) {
2355
- const names = projects.map(p => path.basename(p));
2356
- if (names.length <= 2) return names.join(' + ');
2357
- return `${names[0]} + ${names.length - 1} more`;
2358
- }
2359
-
2360
- /**
2361
- * Clear old activity data
2362
- */
2363
- activityClear(olderThanDays = 30) {
2364
- const data = this.loadActivity();
2365
- const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
2366
-
2367
- data.sessions = data.sessions.filter(s => new Date(s.startedAt) > cutoff);
2368
- data.projectStats = {};
2369
- data.coActivity = {};
2370
-
2371
- for (const session of data.sessions) {
2372
- for (const file of session.files) {
2373
- const projectPath = this.detectProjectRoot(file.path);
2374
- if (projectPath) {
2375
- if (!data.projectStats[projectPath]) {
2376
- data.projectStats[projectPath] = { fileCount: 0, lastActive: session.startedAt, sessionCount: 0 };
2377
- }
2378
- data.projectStats[projectPath].fileCount++;
2379
- if (session.startedAt > data.projectStats[projectPath].lastActive) {
2380
- data.projectStats[projectPath].lastActive = session.startedAt;
2381
- }
2382
- }
2383
- }
2384
-
2385
- const projects = session.projects;
2386
- for (let i = 0; i < projects.length; i++) {
2387
- for (let j = i + 1; j < projects.length; j++) {
2388
- const p1 = projects[i], p2 = projects[j];
2389
- if (!data.coActivity[p1]) data.coActivity[p1] = {};
2390
- if (!data.coActivity[p2]) data.coActivity[p2] = {};
2391
- data.coActivity[p1][p2] = (data.coActivity[p1][p2] || 0) + 1;
2392
- data.coActivity[p2][p1] = (data.coActivity[p2][p1] || 0) + 1;
2393
- }
2394
- }
2395
- }
2396
-
2397
- this.saveActivity(data);
2398
- return { sessionsRemaining: data.sessions.length };
2399
- }
2400
-
2401
- // ===========================================================================
2402
- // SMART SYNC (Phase 3) - Auto-detect and nudge workstream switching
2403
- // ===========================================================================
2404
-
2405
- /**
2406
- * Get path to smart sync preferences file
2407
- */
2408
- getSmartSyncPath() {
2409
- return path.join(this.installDir, 'smart-sync.json');
2410
- }
2411
-
2412
- /**
2413
- * Load smart sync preferences
2414
- */
2415
- loadSmartSyncPrefs() {
2416
- const prefsPath = this.getSmartSyncPath();
2417
- try {
2418
- if (fs.existsSync(prefsPath)) {
2419
- return JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
2420
- }
2421
- } catch (e) {
2422
- // Ignore errors, return defaults
2423
- }
2424
- return {
2425
- enabled: true,
2426
- autoSwitchThreshold: 80, // % of activity that must match
2427
- projectChoices: {}, // { projectPath: { workstreamId, choice: 'always'|'never'|'ask' } }
2428
- dismissedNudges: [], // Array of dismissed nudge keys
2429
- lastActiveWorkstream: null,
2430
- lastNudgeTime: null
2431
- };
2432
- }
2433
-
2434
- /**
2435
- * Save smart sync preferences
2436
- */
2437
- saveSmartSyncPrefs(prefs) {
2438
- const prefsPath = this.getSmartSyncPath();
2439
- fs.writeFileSync(prefsPath, JSON.stringify(prefs, null, 2));
2440
- }
2441
-
2442
- /**
2443
- * Remember user's choice for a project-workstream association
2444
- * @param {string} projectPath - The project path
2445
- * @param {string} workstreamId - The workstream ID
2446
- * @param {string} choice - 'always', 'never', or 'ask'
2447
- */
2448
- smartSyncRememberChoice(projectPath, workstreamId, choice) {
2449
- const prefs = this.loadSmartSyncPrefs();
2450
- prefs.projectChoices[projectPath] = { workstreamId, choice, savedAt: new Date().toISOString() };
2451
- this.saveSmartSyncPrefs(prefs);
2452
- return { success: true, projectPath, workstreamId, choice };
2453
- }
2454
-
2455
- /**
2456
- * Dismiss a nudge so it won't show again
2457
- * @param {string} nudgeKey - Unique key for the nudge (e.g., "switch:ws123" or "add:proj:/path")
2458
- */
2459
- smartSyncDismissNudge(nudgeKey) {
2460
- const prefs = this.loadSmartSyncPrefs();
2461
- if (!prefs.dismissedNudges.includes(nudgeKey)) {
2462
- prefs.dismissedNudges.push(nudgeKey);
2463
- }
2464
- this.saveSmartSyncPrefs(prefs);
2465
- return { success: true, nudgeKey };
2466
- }
2467
-
2468
- /**
2469
- * Update smart sync settings
2470
- */
2471
- smartSyncUpdateSettings(settings) {
2472
- const prefs = this.loadSmartSyncPrefs();
2473
- if (settings.enabled !== undefined) prefs.enabled = settings.enabled;
2474
- if (settings.autoSwitchThreshold !== undefined) prefs.autoSwitchThreshold = settings.autoSwitchThreshold;
2475
- this.saveSmartSyncPrefs(prefs);
2476
- return { success: true, settings: prefs };
2477
- }
2478
-
2479
- /**
2480
- * Detect which workstream best matches current activity
2481
- * @param {string[]} currentProjects - Array of project paths currently being worked on
2482
- * @returns {Object} Detection result with suggested workstream and confidence
2483
- */
2484
- smartSyncDetect(currentProjects = []) {
2485
- const prefs = this.loadSmartSyncPrefs();
2486
- const workstreams = this.loadWorkstreams();
2487
-
2488
- if (!prefs.enabled || !currentProjects.length || !workstreams.workstreams.length) {
2489
- return { suggestion: null, reason: 'disabled_or_no_data' };
2490
- }
2491
-
2492
- // Check for "always" choices first
2493
- for (const projectPath of currentProjects) {
2494
- const choice = prefs.projectChoices[projectPath];
2495
- if (choice && choice.choice === 'always') {
2496
- const ws = workstreams.workstreams.find(w => w.id === choice.workstreamId);
2497
- if (ws) {
2498
- return {
2499
- suggestion: ws,
2500
- confidence: 100,
2501
- reason: 'user_preference',
2502
- autoSwitch: true
2503
- };
2504
- }
2505
- }
2506
- }
2507
-
2508
- // Check for "never" choices - exclude those workstreams
2509
- const excludedWorkstreams = new Set();
2510
- for (const projectPath of currentProjects) {
2511
- const choice = prefs.projectChoices[projectPath];
2512
- if (choice && choice.choice === 'never') {
2513
- excludedWorkstreams.add(choice.workstreamId);
2514
- }
2515
- }
2516
-
2517
- // Score each workstream based on project overlap
2518
- const scores = [];
2519
- for (const ws of workstreams.workstreams) {
2520
- if (excludedWorkstreams.has(ws.id)) continue;
2521
- if (ws.id === workstreams.activeId) continue; // Don't suggest current
2522
-
2523
- const wsProjects = ws.projects || [];
2524
- if (wsProjects.length === 0) continue;
2525
-
2526
- // Calculate overlap
2527
- const matchingProjects = currentProjects.filter(p => wsProjects.includes(p));
2528
- const overlapPercent = (matchingProjects.length / currentProjects.length) * 100;
2529
- const coveragePercent = (matchingProjects.length / wsProjects.length) * 100;
2530
-
2531
- // Combined score: weighted average of overlap and coverage
2532
- const confidence = Math.round((overlapPercent * 0.7) + (coveragePercent * 0.3));
2533
-
2534
- if (confidence > 0) {
2535
- scores.push({
2536
- workstream: ws,
2537
- confidence,
2538
- matchingProjects,
2539
- overlapPercent,
2540
- coveragePercent
2541
- });
2542
- }
2543
- }
2544
-
2545
- // Sort by confidence
2546
- scores.sort((a, b) => b.confidence - a.confidence);
2547
-
2548
- if (scores.length === 0) {
2549
- return { suggestion: null, reason: 'no_matching_workstream' };
2550
- }
2551
-
2552
- const best = scores[0];
2553
- const shouldAutoSwitch = best.confidence >= prefs.autoSwitchThreshold;
2554
-
2555
- return {
2556
- suggestion: best.workstream,
2557
- confidence: best.confidence,
2558
- matchingProjects: best.matchingProjects,
2559
- reason: shouldAutoSwitch ? 'high_confidence_match' : 'partial_match',
2560
- autoSwitch: shouldAutoSwitch,
2561
- alternatives: scores.slice(1, 3).map(s => ({
2562
- workstream: s.workstream,
2563
- confidence: s.confidence
2564
- }))
2565
- };
2566
- }
2567
-
2568
- /**
2569
- * Check if we should show a nudge and what type
2570
- * @param {string[]} currentProjects - Currently active projects
2571
- * @returns {Object|null} Nudge to show, or null if none needed
2572
- */
2573
- smartSyncCheckNudge(currentProjects = []) {
2574
- const prefs = this.loadSmartSyncPrefs();
2575
- const workstreams = this.loadWorkstreams();
2576
- const activeWs = workstreams.workstreams.find(w => w.id === workstreams.activeId);
2577
-
2578
- if (!prefs.enabled || !currentProjects.length) {
2579
- return null;
2580
- }
2581
-
2582
- // Rate limit nudges (max once per 5 minutes)
2583
- if (prefs.lastNudgeTime) {
2584
- const timeSince = Date.now() - new Date(prefs.lastNudgeTime).getTime();
2585
- if (timeSince < 5 * 60 * 1000) {
2586
- return null;
2587
- }
2588
- }
2589
-
2590
- const nudges = [];
2591
-
2592
- // Check 1: Should we suggest switching workstreams?
2593
- const detection = this.smartSyncDetect(currentProjects);
2594
- if (detection.suggestion && detection.confidence >= 50) {
2595
- const nudgeKey = `switch:${detection.suggestion.id}`;
2596
- if (!prefs.dismissedNudges.includes(nudgeKey)) {
2597
- nudges.push({
2598
- type: 'switch',
2599
- key: nudgeKey,
2600
- message: `Working on ${currentProjects.map(p => path.basename(p)).join(', ')}. Switch to "${detection.suggestion.name}"?`,
2601
- workstream: detection.suggestion,
2602
- confidence: detection.confidence,
2603
- autoSwitch: detection.autoSwitch,
2604
- actions: [
2605
- { label: 'Yes', action: 'switch' },
2606
- { label: 'No', action: 'dismiss' },
2607
- { label: 'Always', action: 'always' }
2608
- ]
2609
- });
2610
- }
2611
- }
2612
-
2613
- // Check 2: New project not in active workstream?
2614
- if (activeWs) {
2615
- for (const projectPath of currentProjects) {
2616
- if (!activeWs.projects?.includes(projectPath)) {
2617
- const nudgeKey = `add:${activeWs.id}:${projectPath}`;
2618
- if (!prefs.dismissedNudges.includes(nudgeKey)) {
2619
- // Check if this project isn't in any workstream
2620
- const inOtherWs = workstreams.workstreams.some(
2621
- ws => ws.id !== activeWs.id && ws.projects?.includes(projectPath)
2622
- );
2623
- if (!inOtherWs) {
2624
- nudges.push({
2625
- type: 'add_project',
2626
- key: nudgeKey,
2627
- message: `New project "${path.basename(projectPath)}" detected. Add to "${activeWs.name}"?`,
2628
- workstream: activeWs,
2629
- projectPath,
2630
- actions: [
2631
- { label: 'Yes', action: 'add' },
2632
- { label: 'No', action: 'dismiss' },
2633
- { label: 'Never', action: 'never' }
2634
- ]
2635
- });
2636
- }
2637
- }
2638
- }
2639
- }
2640
- }
2641
-
2642
- if (nudges.length === 0) {
2643
- return null;
2644
- }
2645
-
2646
- // Return the highest priority nudge (switch > add_project)
2647
- const nudge = nudges.find(n => n.type === 'switch') || nudges[0];
2648
-
2649
- // Update last nudge time
2650
- prefs.lastNudgeTime = new Date().toISOString();
2651
- this.saveSmartSyncPrefs(prefs);
2652
-
2653
- return nudge;
2654
- }
2655
-
2656
- /**
2657
- * Handle a nudge action
2658
- * @param {string} nudgeKey - The nudge key
2659
- * @param {string} action - The action taken ('switch', 'add', 'dismiss', 'always', 'never')
2660
- * @param {Object} context - Additional context (workstreamId, projectPath, etc.)
2661
- */
2662
- smartSyncHandleAction(nudgeKey, action, context = {}) {
2663
- const prefs = this.loadSmartSyncPrefs();
2664
-
2665
- switch (action) {
2666
- case 'switch':
2667
- // Switch to the suggested workstream
2668
- if (context.workstreamId) {
2669
- this.workstreamUse(context.workstreamId);
2670
- }
2671
- break;
2672
-
2673
- case 'add':
2674
- // Add project to workstream
2675
- if (context.workstreamId && context.projectPath) {
2676
- this.workstreamAddProject(context.workstreamId, context.projectPath);
2677
- }
2678
- break;
2679
-
2680
- case 'always':
2681
- // Remember to always use this workstream for these projects
2682
- if (context.workstreamId && context.projects) {
2683
- for (const projectPath of context.projects) {
2684
- this.smartSyncRememberChoice(projectPath, context.workstreamId, 'always');
2685
- }
2686
- }
2687
- // Also switch
2688
- if (context.workstreamId) {
2689
- this.workstreamUse(context.workstreamId);
2690
- }
2691
- break;
2692
-
2693
- case 'never':
2694
- // Remember to never suggest this
2695
- if (context.workstreamId && context.projectPath) {
2696
- this.smartSyncRememberChoice(context.projectPath, context.workstreamId, 'never');
2697
- }
2698
- this.smartSyncDismissNudge(nudgeKey);
2699
- break;
2700
-
2701
- case 'dismiss':
2702
- // Just dismiss this nudge
2703
- this.smartSyncDismissNudge(nudgeKey);
2704
- break;
2705
- }
2706
-
2707
- return { success: true, action, nudgeKey };
2708
- }
2709
-
2710
- /**
2711
- * Get smart sync status and settings
2712
- */
2713
- smartSyncStatus() {
2714
- const prefs = this.loadSmartSyncPrefs();
2715
- const activity = this.loadActivity();
2716
-
2717
- // Get recent projects from activity
2718
- const recentProjects = [];
2719
- const recentSessions = activity.sessions.slice(-5);
2720
- for (const session of recentSessions) {
2721
- for (const proj of session.projects || []) {
2722
- if (!recentProjects.includes(proj)) {
2723
- recentProjects.push(proj);
2724
- }
2725
- }
2726
- }
2727
-
2728
- return {
2729
- enabled: prefs.enabled,
2730
- autoSwitchThreshold: prefs.autoSwitchThreshold,
2731
- savedChoicesCount: Object.keys(prefs.projectChoices).length,
2732
- dismissedNudgesCount: prefs.dismissedNudges.length,
2733
- recentProjects: recentProjects.slice(0, 10),
2734
- lastNudgeTime: prefs.lastNudgeTime
2735
- };
2736
- }
2737
- }
2738
-
2739
- // =============================================================================
2740
- // CLI
2741
- // =============================================================================
236
+ // =============================================================================
237
+ // CLI
238
+ // =============================================================================
2742
239
 
2743
240
  if (require.main === module) {
2744
- const args = process.argv.slice(2);
2745
- const command = args[0];
2746
241
  const manager = new ClaudeConfigManager();
2747
-
2748
- // Parse --template flag for init
2749
- const templateIndex = args.indexOf('--template');
2750
- const templateArg = templateIndex !== -1 ? args[templateIndex + 1] : null;
2751
-
2752
- switch (command) {
2753
- // Core
2754
- case 'init':
2755
- if (templateArg) {
2756
- // Remove --template and its value from args for path detection
2757
- const filteredArgs = args.filter((_, i) => i !== templateIndex && i !== templateIndex + 1);
2758
- manager.init(filteredArgs[1], templateArg);
2759
- } else {
2760
- manager.init(args[1]);
2761
- }
2762
- break;
2763
- case 'apply':
2764
- manager.apply(args[1]);
2765
- break;
2766
- case 'apply-template':
2767
- manager.applyTemplate(args[1], args[2]);
2768
- break;
2769
- case 'show':
2770
- manager.show(args[1]);
2771
- break;
2772
- case 'list':
2773
- case 'mcps':
2774
- manager.list();
2775
- break;
2776
- case 'templates':
2777
- manager.listTemplates();
2778
- break;
2779
-
2780
- // Edit MCPs
2781
- case 'add':
2782
- manager.add(args.slice(1));
2783
- break;
2784
- case 'remove':
2785
- case 'rm':
2786
- manager.remove(args.slice(1));
2787
- break;
2788
-
2789
- // Registry management
2790
- case 'registry-add':
2791
- manager.registryAdd(args[1], args[2]);
2792
- break;
2793
- case 'registry-remove':
2794
- case 'registry-rm':
2795
- manager.registryRemove(args[1]);
2796
- break;
2797
-
2798
- // Memory
2799
- case 'memory':
2800
- if (args[1] === 'init') {
2801
- manager.memoryInit(args[2]);
2802
- } else if (args[1] === 'add') {
2803
- manager.memoryAdd(args[2], args.slice(3).join(' '));
2804
- } else if (args[1] === 'search') {
2805
- manager.memorySearch(args.slice(2).join(' '));
2806
- } else {
2807
- manager.memoryList();
2808
- }
2809
- break;
2810
-
2811
- // Environment
2812
- case 'env':
2813
- if (args[1] === 'set') {
2814
- manager.envSet(args[2], args[3]);
2815
- } else if (args[1] === 'unset') {
2816
- manager.envUnset(args[2]);
2817
- } else {
2818
- manager.envList();
2819
- }
2820
- break;
2821
-
2822
- // Project registry (for UI)
2823
- case 'project':
2824
- case 'projects':
2825
- if (args[1] === 'add') {
2826
- const nameIdx = args.indexOf('--name');
2827
- const name = nameIdx !== -1 ? args[nameIdx + 1] : null;
2828
- const projectPath = args[2] && !args[2].startsWith('--') ? args[2] : process.cwd();
2829
- manager.projectAdd(projectPath, name);
2830
- } else if (args[1] === 'remove' || args[1] === 'rm') {
2831
- manager.projectRemove(args[2]);
2832
- } else {
2833
- manager.projectList();
2834
- }
2835
- break;
2836
-
2837
- // Workstreams
2838
- case 'workstream':
2839
- case 'ws':
2840
- if (args[1] === 'create' || args[1] === 'new') {
2841
- manager.workstreamCreate(args[2]);
2842
- } else if (args[1] === 'delete' || args[1] === 'rm') {
2843
- manager.workstreamDelete(args[2]);
2844
- } else if (args[1] === 'use' || args[1] === 'switch') {
2845
- manager.workstreamUse(args[2]);
2846
- } else if (args[1] === 'add-project') {
2847
- manager.workstreamAddProject(args[2], args[3]);
2848
- } else if (args[1] === 'remove-project') {
2849
- manager.workstreamRemoveProject(args[2], args[3]);
2850
- } else if (args[1] === 'inject') {
2851
- const silent = args.includes('--silent') || args.includes('-s');
2852
- manager.workstreamInject(silent);
2853
- } else if (args[1] === 'detect') {
2854
- const ws = manager.workstreamDetect(args[2] || process.cwd());
2855
- if (ws) {
2856
- console.log(ws.name);
2857
- }
2858
- } else if (args[1] === 'active') {
2859
- const ws = manager.workstreamActive();
2860
- if (ws) {
2861
- console.log(`Active: ${ws.name}`);
2862
- if (ws.projects.length > 0) {
2863
- console.log(`Projects: ${ws.projects.map(p => path.basename(p)).join(', ')}`);
2864
- }
2865
- } else {
2866
- console.log('No active workstream');
2867
- }
2868
- } else {
2869
- manager.workstreamList();
2870
- }
2871
- break;
2872
-
2873
- // Maintenance
2874
- case 'update':
2875
- manager.update(args[1]);
2876
- break;
2877
- case 'ui': {
2878
- const UIServer = require('./ui/server.cjs');
2879
- const port = parseInt(args.find(a => a.startsWith('--port='))?.split('=')[1] || '3333');
2880
- const uiDir = args.find(a => !a.startsWith('--') && a !== 'ui') || process.cwd();
2881
- const uiServer = new UIServer(port, uiDir, manager);
2882
- uiServer.start();
2883
- break;
2884
- }
2885
- case 'version':
2886
- case '-v':
2887
- case '--version':
2888
- manager.version();
2889
- break;
2890
-
2891
- default:
2892
- console.log(`
2893
- claude-config v${VERSION}
2894
-
2895
- Usage:
2896
- claude-config <command> [args]
2897
-
2898
- Project Commands:
2899
- init [--template <name>] Initialize project (optionally with template)
2900
- apply Generate .mcp.json from config
2901
- apply-template <name> Add template rules/commands to existing project
2902
- show Show current project config
2903
- list List available MCPs (✓ = active)
2904
- templates List available templates
2905
- add <mcp> [mcp...] Add MCP(s) to project
2906
- remove <mcp> [mcp...] Remove MCP(s) from project
2907
-
2908
- Memory Commands:
2909
- memory Show memory status
2910
- memory init Initialize project memory
2911
- memory add <type> <content> Add entry (types: preference, correction, fact,
2912
- context, pattern, decision, issue, history)
2913
- memory search <query> Search all memory files
2914
-
2915
- Environment Commands:
2916
- env List environment variables
2917
- env set <KEY> <value> Set variable in .claude/.env
2918
- env unset <KEY> Remove variable
2919
-
2920
- Project Commands (for UI):
2921
- project List registered projects
2922
- project add [path] Add project (defaults to cwd)
2923
- project add [path] --name X Add with custom display name
2924
- project remove <name|path> Remove project from registry
2925
-
2926
- Workstream Commands:
2927
- workstream List all workstreams
2928
- workstream create "Name" Create new workstream
2929
- workstream delete <name> Delete workstream
2930
- workstream use <name> Set active workstream
2931
- workstream active Show current active workstream
2932
- workstream add-project <ws> <path> Add project to workstream
2933
- workstream remove-project <ws> <path> Remove project from workstream
2934
- workstream inject [--silent] Output active workstream rules (for hooks)
2935
- workstream detect [path] Detect workstream for directory
2936
-
2937
- Registry Commands:
2938
- registry-add <name> '<json>' Add MCP to global registry
2939
- registry-remove <name> Remove MCP from registry
2940
-
2941
- Maintenance:
2942
- ui [--port=3333] Open web UI
2943
- version Show version info
2944
-
2945
- Examples:
2946
- claude-config init --template fastapi
2947
- claude-config add postgres github
2948
- claude-config memory add preference "Use TypeScript for new files"
2949
- claude-config env set GITHUB_TOKEN ghp_xxx
2950
- claude-config apply
2951
- `);
2952
- }
242
+ runCli(manager);
2953
243
  }
2954
244
 
2955
245
  module.exports = ClaudeConfigManager;