ccmanager 3.3.2 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +11 -5
  2. package/dist/components/App.js +17 -3
  3. package/dist/components/App.test.js +5 -5
  4. package/dist/components/Configuration.d.ts +2 -0
  5. package/dist/components/Configuration.js +6 -2
  6. package/dist/components/ConfigureCommand.js +34 -11
  7. package/dist/components/ConfigureOther.js +18 -4
  8. package/dist/components/ConfigureOther.test.js +48 -12
  9. package/dist/components/ConfigureShortcuts.js +27 -85
  10. package/dist/components/ConfigureStatusHooks.js +19 -4
  11. package/dist/components/ConfigureStatusHooks.test.js +46 -12
  12. package/dist/components/ConfigureWorktree.js +18 -4
  13. package/dist/components/ConfigureWorktreeHooks.js +19 -4
  14. package/dist/components/ConfigureWorktreeHooks.test.js +49 -14
  15. package/dist/components/Menu.js +72 -14
  16. package/dist/components/Menu.recent-projects.test.js +2 -0
  17. package/dist/components/Menu.test.js +2 -0
  18. package/dist/components/NewWorktree.js +2 -2
  19. package/dist/components/NewWorktree.test.js +6 -6
  20. package/dist/components/PresetSelector.js +2 -2
  21. package/dist/constants/statusIcons.d.ts +4 -1
  22. package/dist/constants/statusIcons.js +10 -1
  23. package/dist/constants/statusIcons.test.js +42 -0
  24. package/dist/contexts/ConfigEditorContext.d.ts +21 -0
  25. package/dist/contexts/ConfigEditorContext.js +25 -0
  26. package/dist/services/autoApprovalVerifier.js +3 -3
  27. package/dist/services/autoApprovalVerifier.test.js +2 -2
  28. package/dist/services/config/configEditor.d.ts +46 -0
  29. package/dist/services/{configurationManager.effect.test.js → config/configEditor.effect.test.js} +46 -49
  30. package/dist/services/config/configEditor.js +101 -0
  31. package/dist/services/{configurationManager.selectPresetOnStart.test.js → config/configEditor.selectPresetOnStart.test.js} +27 -19
  32. package/dist/services/config/configEditor.test.d.ts +1 -0
  33. package/dist/services/{configurationManager.test.js → config/configEditor.test.js} +60 -132
  34. package/dist/services/config/configReader.d.ts +28 -0
  35. package/dist/services/config/configReader.js +95 -0
  36. package/dist/services/config/configReader.multiProject.test.d.ts +1 -0
  37. package/dist/services/config/configReader.multiProject.test.js +136 -0
  38. package/dist/services/config/globalConfigManager.d.ts +30 -0
  39. package/dist/services/config/globalConfigManager.js +216 -0
  40. package/dist/services/config/index.d.ts +13 -0
  41. package/dist/services/config/index.js +13 -0
  42. package/dist/services/config/projectConfigManager.d.ts +41 -0
  43. package/dist/services/config/projectConfigManager.js +181 -0
  44. package/dist/services/config/projectConfigManager.test.d.ts +1 -0
  45. package/dist/services/config/projectConfigManager.test.js +105 -0
  46. package/dist/services/config/testUtils.d.ts +81 -0
  47. package/dist/services/config/testUtils.js +351 -0
  48. package/dist/services/sessionManager.autoApproval.test.js +9 -6
  49. package/dist/services/sessionManager.d.ts +2 -0
  50. package/dist/services/sessionManager.effect.test.js +27 -18
  51. package/dist/services/sessionManager.js +43 -40
  52. package/dist/services/sessionManager.statePersistence.test.js +5 -4
  53. package/dist/services/sessionManager.test.js +71 -49
  54. package/dist/services/shortcutManager.d.ts +0 -1
  55. package/dist/services/shortcutManager.js +5 -16
  56. package/dist/services/shortcutManager.test.js +2 -2
  57. package/dist/services/stateDetector/base.d.ts +1 -0
  58. package/dist/services/stateDetector/claude.d.ts +1 -0
  59. package/dist/services/stateDetector/claude.js +8 -0
  60. package/dist/services/stateDetector/claude.test.js +102 -0
  61. package/dist/services/stateDetector/cline.d.ts +1 -0
  62. package/dist/services/stateDetector/cline.js +3 -0
  63. package/dist/services/stateDetector/codex.d.ts +1 -0
  64. package/dist/services/stateDetector/codex.js +3 -0
  65. package/dist/services/stateDetector/cursor.d.ts +1 -0
  66. package/dist/services/stateDetector/cursor.js +3 -0
  67. package/dist/services/stateDetector/gemini.d.ts +1 -0
  68. package/dist/services/stateDetector/gemini.js +3 -0
  69. package/dist/services/stateDetector/github-copilot.d.ts +1 -0
  70. package/dist/services/stateDetector/github-copilot.js +3 -0
  71. package/dist/services/stateDetector/opencode.d.ts +1 -0
  72. package/dist/services/stateDetector/opencode.js +3 -0
  73. package/dist/services/stateDetector/types.d.ts +1 -0
  74. package/dist/services/worktreeService.d.ts +12 -0
  75. package/dist/services/worktreeService.js +24 -4
  76. package/dist/services/worktreeService.sort.test.js +105 -109
  77. package/dist/services/worktreeService.test.js +5 -5
  78. package/dist/types/index.d.ts +47 -7
  79. package/dist/utils/gitUtils.d.ts +8 -0
  80. package/dist/utils/gitUtils.js +32 -0
  81. package/dist/utils/hookExecutor.js +2 -2
  82. package/dist/utils/hookExecutor.test.js +13 -12
  83. package/dist/utils/mutex.d.ts +1 -0
  84. package/dist/utils/mutex.js +1 -0
  85. package/dist/utils/worktreeUtils.js +3 -2
  86. package/dist/utils/worktreeUtils.test.js +2 -1
  87. package/package.json +7 -7
  88. package/dist/services/configurationManager.d.ts +0 -121
  89. package/dist/services/configurationManager.js +0 -597
  90. /package/dist/{services/configurationManager.effect.test.d.ts → constants/statusIcons.test.d.ts} +0 -0
  91. /package/dist/services/{configurationManager.selectPresetOnStart.test.d.ts → config/configEditor.effect.test.d.ts} +0 -0
  92. /package/dist/services/{configurationManager.test.d.ts → config/configEditor.selectPresetOnStart.test.d.ts} +0 -0
@@ -0,0 +1,25 @@
1
+ import React, { createContext, useContext, useMemo } from 'react';
2
+ import { ConfigEditor } from '../services/config/configEditor.js';
3
+ const ConfigEditorContext = createContext(null);
4
+ /**
5
+ * Provider component for ConfigEditor context.
6
+ * Creates a ConfigEditor instance based on scope.
7
+ * Uses singleton config editors to ensure config changes are
8
+ * immediately visible to all components.
9
+ */
10
+ export function ConfigEditorProvider({ scope, children, }) {
11
+ const configEditor = useMemo(() => new ConfigEditor(scope), [scope]);
12
+ return (React.createElement(ConfigEditorContext.Provider, { value: configEditor }, children));
13
+ }
14
+ /**
15
+ * Hook to access ConfigEditor from context.
16
+ * Must be used within a ConfigEditorProvider.
17
+ */
18
+ export function useConfigEditor() {
19
+ const context = useContext(ConfigEditorContext);
20
+ if (context === null) {
21
+ throw new Error('useConfigEditor must be used within a ConfigEditorProvider');
22
+ }
23
+ return context;
24
+ }
25
+ export { ConfigEditorContext };
@@ -1,11 +1,11 @@
1
1
  import { Effect } from 'effect';
2
2
  import { ProcessError } from '../types/errors.js';
3
- import { configurationManager } from './configurationManager.js';
3
+ import { configReader } from './config/configReader.js';
4
4
  import { logger } from '../utils/logger.js';
5
5
  import { execFile, spawn, } from 'child_process';
6
6
  const DEFAULT_TIMEOUT_SECONDS = 30;
7
7
  const getTimeoutMs = () => {
8
- const config = configurationManager.getAutoApprovalConfig();
8
+ const config = configReader.getAutoApprovalConfig();
9
9
  const timeoutSeconds = config.timeout ?? DEFAULT_TIMEOUT_SECONDS;
10
10
  return timeoutSeconds * 1000;
11
11
  };
@@ -222,7 +222,7 @@ export class AutoApprovalVerifier {
222
222
  verifyNeedsPermission(terminalOutput, options) {
223
223
  const attemptVerification = Effect.tryPromise({
224
224
  try: async () => {
225
- const autoApprovalConfig = configurationManager.getAutoApprovalConfig();
225
+ const autoApprovalConfig = configReader.getAutoApprovalConfig();
226
226
  const customCommand = autoApprovalConfig.customCommand?.trim();
227
227
  const prompt = buildPrompt(terminalOutput);
228
228
  const jsonSchema = JSON.stringify({
@@ -5,8 +5,8 @@ const execFileMock = vi.fn();
5
5
  vi.mock('child_process', () => ({
6
6
  execFile: (...args) => execFileMock(...args),
7
7
  }));
8
- vi.mock('./configurationManager.js', () => ({
9
- configurationManager: {
8
+ vi.mock('./config/configReader.js', () => ({
9
+ configReader: {
10
10
  getAutoApprovalConfig: vi.fn().mockReturnValue({ enabled: false }),
11
11
  },
12
12
  }));
@@ -0,0 +1,46 @@
1
+ import { ConfigScope, ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
2
+ /**
3
+ * ConfigEditor provides scope-aware configuration editing.
4
+ * The scope is determined at construction time.
5
+ *
6
+ * - When scope='global', uses GlobalConfigManager singleton
7
+ * - When scope='project', uses ProjectConfigManager singleton
8
+ * (with fallback to global if project value is undefined)
9
+ *
10
+ * IMPORTANT: Uses singletons to ensure that config changes are
11
+ * immediately visible to all components (e.g., shortcutManager, configReader).
12
+ */
13
+ export declare class ConfigEditor implements IConfigEditor {
14
+ private scope;
15
+ private configEditor;
16
+ constructor(scope: ConfigScope);
17
+ getShortcuts(): ShortcutConfig | undefined;
18
+ setShortcuts(value: ShortcutConfig): void;
19
+ getStatusHooks(): StatusHookConfig | undefined;
20
+ setStatusHooks(value: StatusHookConfig): void;
21
+ getWorktreeHooks(): WorktreeHookConfig | undefined;
22
+ setWorktreeHooks(value: WorktreeHookConfig): void;
23
+ getWorktreeConfig(): WorktreeConfig | undefined;
24
+ setWorktreeConfig(value: WorktreeConfig): void;
25
+ getCommandPresets(): CommandPresetsConfig | undefined;
26
+ setCommandPresets(value: CommandPresetsConfig): void;
27
+ getAutoApprovalConfig(): AutoApprovalConfig | undefined;
28
+ setAutoApprovalConfig(value: AutoApprovalConfig): void;
29
+ reload(): void;
30
+ /**
31
+ * Get the current scope
32
+ */
33
+ getScope(): ConfigScope;
34
+ /**
35
+ * Check if project has an override for a specific field
36
+ */
37
+ hasProjectOverride(field: keyof ProjectConfigurationData): boolean;
38
+ /**
39
+ * Remove project override for a specific field
40
+ */
41
+ removeProjectOverride(field: keyof ProjectConfigurationData): void;
42
+ }
43
+ /**
44
+ * Factory function to create a ConfigEditor instance
45
+ */
46
+ export declare function createConfigEditor(scope: ConfigScope): ConfigEditor;
@@ -1,7 +1,11 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { Effect, Either } from 'effect';
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
4
- import { ConfigurationManager } from './configurationManager.js';
4
+ import { ConfigEditor } from './configEditor.js';
5
+ import { loadConfigEffect, validateConfig, saveConfigEffect, setShortcutsEffect, setCommandPresetsEffect, addPresetEffect, deletePresetEffect, setDefaultPresetEffect, getPresetByIdEffect, } from './testUtils.js';
6
+ // Test paths (matching what GlobalConfigManager constructs with mocked homedir)
7
+ const TEST_CONFIG_PATH = '/home/test/.config/ccmanager/config.json';
8
+ const TEST_LEGACY_SHORTCUTS_PATH = '/home/test/.config/ccmanager/shortcuts.json';
5
9
  // Mock fs module
6
10
  vi.mock('fs', () => ({
7
11
  existsSync: vi.fn(),
@@ -13,8 +17,8 @@ vi.mock('fs', () => ({
13
17
  vi.mock('os', () => ({
14
18
  homedir: vi.fn(() => '/home/test'),
15
19
  }));
16
- describe('ConfigurationManager - Effect-based operations', () => {
17
- let configManager;
20
+ describe('ConfigEditor (global scope) - Effect-based operations', () => {
21
+ let configEditor;
18
22
  let mockConfigData;
19
23
  beforeEach(() => {
20
24
  // Reset all mocks
@@ -25,10 +29,6 @@ describe('ConfigurationManager - Effect-based operations', () => {
25
29
  returnToMenu: { ctrl: true, key: 'e' },
26
30
  cancel: { key: 'escape' },
27
31
  },
28
- command: {
29
- command: 'claude',
30
- args: ['--existing'],
31
- },
32
32
  };
33
33
  // Mock file system operations
34
34
  existsSync.mockImplementation((path) => {
@@ -39,25 +39,24 @@ describe('ConfigurationManager - Effect-based operations', () => {
39
39
  });
40
40
  mkdirSync.mockImplementation(() => { });
41
41
  writeFileSync.mockImplementation(() => { });
42
- // Create new instance for each test
43
- configManager = new ConfigurationManager();
42
+ // Create new instance for each test and reload to pick up mocked fs
43
+ configEditor = new ConfigEditor('global');
44
+ configEditor.reload();
44
45
  });
45
46
  afterEach(() => {
46
47
  vi.resetAllMocks();
47
48
  });
48
49
  describe('loadConfigEffect', () => {
49
50
  it('should return Effect with ConfigurationData on success', async () => {
50
- const result = await Effect.runPromise(configManager.loadConfigEffect());
51
+ const result = await Effect.runPromise(loadConfigEffect(TEST_CONFIG_PATH, TEST_LEGACY_SHORTCUTS_PATH));
51
52
  expect(result).toBeDefined();
52
53
  expect(result.shortcuts).toBeDefined();
53
- expect(result.command).toBeDefined();
54
54
  });
55
55
  it('should fail with FileSystemError when file read fails', async () => {
56
56
  readFileSync.mockImplementation(() => {
57
57
  throw new Error('EACCES: permission denied');
58
58
  });
59
- configManager = new ConfigurationManager();
60
- const result = await Effect.runPromise(Effect.either(configManager.loadConfigEffect()));
59
+ const result = await Effect.runPromise(Effect.either(loadConfigEffect(TEST_CONFIG_PATH, TEST_LEGACY_SHORTCUTS_PATH)));
61
60
  expect(Either.isLeft(result)).toBe(true);
62
61
  if (Either.isLeft(result)) {
63
62
  expect(result.left._tag).toBe('FileSystemError');
@@ -69,8 +68,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
69
68
  readFileSync.mockImplementation(() => {
70
69
  return 'invalid json{';
71
70
  });
72
- configManager = new ConfigurationManager();
73
- const result = await Effect.runPromise(Effect.either(configManager.loadConfigEffect()));
71
+ const result = await Effect.runPromise(Effect.either(loadConfigEffect(TEST_CONFIG_PATH, TEST_LEGACY_SHORTCUTS_PATH)));
74
72
  expect(Either.isLeft(result)).toBe(true);
75
73
  if (Either.isLeft(result)) {
76
74
  expect(result.left._tag).toBe('ConfigError');
@@ -94,8 +92,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
94
92
  }
95
93
  return '{}';
96
94
  });
97
- configManager = new ConfigurationManager();
98
- const result = await Effect.runPromise(configManager.loadConfigEffect());
95
+ const result = await Effect.runPromise(loadConfigEffect(TEST_CONFIG_PATH, TEST_LEGACY_SHORTCUTS_PATH));
99
96
  expect(result.shortcuts).toEqual(legacyShortcuts);
100
97
  expect(writeFileSync).toHaveBeenCalled();
101
98
  });
@@ -108,7 +105,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
108
105
  cancel: { key: 'escape' },
109
106
  },
110
107
  };
111
- await Effect.runPromise(configManager.saveConfigEffect(newConfig));
108
+ await Effect.runPromise(saveConfigEffect(configEditor, newConfig, TEST_CONFIG_PATH));
112
109
  expect(writeFileSync).toHaveBeenCalledWith(expect.stringContaining('config.json'), expect.any(String));
113
110
  });
114
111
  it('should fail with FileSystemError when file write fails', async () => {
@@ -121,7 +118,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
121
118
  cancel: { key: 'escape' },
122
119
  },
123
120
  };
124
- const result = await Effect.runPromise(Effect.either(configManager.saveConfigEffect(newConfig)));
121
+ const result = await Effect.runPromise(Effect.either(saveConfigEffect(configEditor, newConfig, TEST_CONFIG_PATH)));
125
122
  expect(Either.isLeft(result)).toBe(true);
126
123
  if (Either.isLeft(result)) {
127
124
  expect(result.left._tag).toBe('FileSystemError');
@@ -138,7 +135,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
138
135
  cancel: { key: 'escape' },
139
136
  },
140
137
  };
141
- const result = configManager.validateConfig(validConfig);
138
+ const result = validateConfig(validConfig);
142
139
  expect(Either.isRight(result)).toBe(true);
143
140
  if (Either.isRight(result)) {
144
141
  expect(result.right).toEqual(validConfig);
@@ -148,7 +145,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
148
145
  const invalidConfig = {
149
146
  shortcuts: 'not an object',
150
147
  };
151
- const result = configManager.validateConfig(invalidConfig);
148
+ const result = validateConfig(invalidConfig);
152
149
  expect(Either.isLeft(result)).toBe(true);
153
150
  if (Either.isLeft(result)) {
154
151
  const error = result.left;
@@ -157,7 +154,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
157
154
  }
158
155
  });
159
156
  it('should return Left for null config', () => {
160
- const result = configManager.validateConfig(null);
157
+ const result = validateConfig(null);
161
158
  expect(Either.isLeft(result)).toBe(true);
162
159
  if (Either.isLeft(result)) {
163
160
  const error = result.left;
@@ -174,8 +171,8 @@ describe('ConfigurationManager - Effect-based operations', () => {
174
171
  ],
175
172
  defaultPresetId: '1',
176
173
  };
177
- configManager = new ConfigurationManager();
178
- const result = configManager.getPresetByIdEffect('2');
174
+ configEditor.reload();
175
+ const result = getPresetByIdEffect(configEditor, '2');
179
176
  expect(Either.isRight(result)).toBe(true);
180
177
  if (Either.isRight(result)) {
181
178
  expect(result.right).toEqual({
@@ -191,8 +188,8 @@ describe('ConfigurationManager - Effect-based operations', () => {
191
188
  presets: [{ id: '1', name: 'Main', command: 'claude' }],
192
189
  defaultPresetId: '1',
193
190
  };
194
- configManager = new ConfigurationManager();
195
- const result = configManager.getPresetByIdEffect('999');
191
+ configEditor.reload();
192
+ const result = getPresetByIdEffect(configEditor, '999');
196
193
  expect(Either.isLeft(result)).toBe(true);
197
194
  if (Either.isLeft(result)) {
198
195
  const error = result.left;
@@ -206,8 +203,8 @@ describe('ConfigurationManager - Effect-based operations', () => {
206
203
  presets: [{ id: '1', name: 'Main', command: 'claude' }],
207
204
  defaultPresetId: '1',
208
205
  };
209
- configManager = new ConfigurationManager();
210
- const result = configManager.getPresetByIdEffect('invalid-id');
206
+ configEditor.reload();
207
+ const result = getPresetByIdEffect(configEditor, 'invalid-id');
211
208
  expect(Either.isLeft(result)).toBe(true);
212
209
  if (Either.isLeft(result)) {
213
210
  const error = result.left;
@@ -221,7 +218,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
221
218
  returnToMenu: { ctrl: true, key: 'z' },
222
219
  cancel: { key: 'escape' },
223
220
  };
224
- await Effect.runPromise(configManager.setShortcutsEffect(newShortcuts));
221
+ await Effect.runPromise(setShortcutsEffect(configEditor, newShortcuts, TEST_CONFIG_PATH));
225
222
  expect(writeFileSync).toHaveBeenCalled();
226
223
  });
227
224
  it('should fail with FileSystemError when save fails', async () => {
@@ -232,7 +229,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
232
229
  returnToMenu: { ctrl: true, key: 'z' },
233
230
  cancel: { key: 'escape' },
234
231
  };
235
- const result = await Effect.runPromise(Effect.either(configManager.setShortcutsEffect(newShortcuts)));
232
+ const result = await Effect.runPromise(Effect.either(setShortcutsEffect(configEditor, newShortcuts, TEST_CONFIG_PATH)));
236
233
  expect(Either.isLeft(result)).toBe(true);
237
234
  if (Either.isLeft(result)) {
238
235
  expect(result.left._tag).toBe('FileSystemError');
@@ -248,7 +245,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
248
245
  ],
249
246
  defaultPresetId: '2',
250
247
  };
251
- await Effect.runPromise(configManager.setCommandPresetsEffect(newPresets));
248
+ await Effect.runPromise(setCommandPresetsEffect(configEditor, newPresets, TEST_CONFIG_PATH));
252
249
  expect(writeFileSync).toHaveBeenCalled();
253
250
  });
254
251
  it('should fail with FileSystemError when save fails', async () => {
@@ -259,7 +256,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
259
256
  presets: [{ id: '1', name: 'Main', command: 'claude' }],
260
257
  defaultPresetId: '1',
261
258
  };
262
- const result = await Effect.runPromise(Effect.either(configManager.setCommandPresetsEffect(newPresets)));
259
+ const result = await Effect.runPromise(Effect.either(setCommandPresetsEffect(configEditor, newPresets, TEST_CONFIG_PATH)));
263
260
  expect(Either.isLeft(result)).toBe(true);
264
261
  if (Either.isLeft(result)) {
265
262
  expect(result.left._tag).toBe('FileSystemError');
@@ -272,14 +269,14 @@ describe('ConfigurationManager - Effect-based operations', () => {
272
269
  presets: [{ id: '1', name: 'Main', command: 'claude' }],
273
270
  defaultPresetId: '1',
274
271
  };
275
- configManager = new ConfigurationManager();
272
+ configEditor.reload();
276
273
  const newPreset = {
277
274
  id: '2',
278
275
  name: 'New',
279
276
  command: 'claude',
280
277
  args: ['--new'],
281
278
  };
282
- await Effect.runPromise(configManager.addPresetEffect(newPreset));
279
+ await Effect.runPromise(addPresetEffect(configEditor, newPreset, TEST_CONFIG_PATH));
283
280
  expect(writeFileSync).toHaveBeenCalled();
284
281
  });
285
282
  it('should replace existing preset with same id', async () => {
@@ -287,14 +284,14 @@ describe('ConfigurationManager - Effect-based operations', () => {
287
284
  presets: [{ id: '1', name: 'Main', command: 'claude' }],
288
285
  defaultPresetId: '1',
289
286
  };
290
- configManager = new ConfigurationManager();
287
+ configEditor.reload();
291
288
  const updatedPreset = {
292
289
  id: '1',
293
290
  name: 'Updated',
294
291
  command: 'claude',
295
292
  args: ['--updated'],
296
293
  };
297
- await Effect.runPromise(configManager.addPresetEffect(updatedPreset));
294
+ await Effect.runPromise(addPresetEffect(configEditor, updatedPreset, TEST_CONFIG_PATH));
298
295
  expect(writeFileSync).toHaveBeenCalled();
299
296
  });
300
297
  it('should fail with FileSystemError when save fails', async () => {
@@ -306,7 +303,7 @@ describe('ConfigurationManager - Effect-based operations', () => {
306
303
  name: 'New',
307
304
  command: 'claude',
308
305
  };
309
- const result = await Effect.runPromise(Effect.either(configManager.addPresetEffect(newPreset)));
306
+ const result = await Effect.runPromise(Effect.either(addPresetEffect(configEditor, newPreset, TEST_CONFIG_PATH)));
310
307
  expect(Either.isLeft(result)).toBe(true);
311
308
  if (Either.isLeft(result)) {
312
309
  expect(result.left._tag).toBe('FileSystemError');
@@ -322,8 +319,8 @@ describe('ConfigurationManager - Effect-based operations', () => {
322
319
  ],
323
320
  defaultPresetId: '1',
324
321
  };
325
- configManager = new ConfigurationManager();
326
- await Effect.runPromise(configManager.deletePresetEffect('2'));
322
+ configEditor.reload();
323
+ await Effect.runPromise(deletePresetEffect(configEditor, '2', TEST_CONFIG_PATH));
327
324
  expect(writeFileSync).toHaveBeenCalled();
328
325
  });
329
326
  it('should fail with ValidationError when deleting last preset', async () => {
@@ -331,8 +328,8 @@ describe('ConfigurationManager - Effect-based operations', () => {
331
328
  presets: [{ id: '1', name: 'Main', command: 'claude' }],
332
329
  defaultPresetId: '1',
333
330
  };
334
- configManager = new ConfigurationManager();
335
- const result = await Effect.runPromise(Effect.either(configManager.deletePresetEffect('1')));
331
+ configEditor.reload();
332
+ const result = await Effect.runPromise(Effect.either(deletePresetEffect(configEditor, '1', TEST_CONFIG_PATH)));
336
333
  expect(Either.isLeft(result)).toBe(true);
337
334
  if (Either.isLeft(result)) {
338
335
  expect(result.left._tag).toBe('ValidationError');
@@ -348,11 +345,11 @@ describe('ConfigurationManager - Effect-based operations', () => {
348
345
  ],
349
346
  defaultPresetId: '1',
350
347
  };
351
- configManager = new ConfigurationManager();
348
+ configEditor.reload();
352
349
  writeFileSync.mockImplementation(() => {
353
350
  throw new Error('Save failed');
354
351
  });
355
- const result = await Effect.runPromise(Effect.either(configManager.deletePresetEffect('2')));
352
+ const result = await Effect.runPromise(Effect.either(deletePresetEffect(configEditor, '2', TEST_CONFIG_PATH)));
356
353
  expect(Either.isLeft(result)).toBe(true);
357
354
  if (Either.isLeft(result)) {
358
355
  expect(result.left._tag).toBe('FileSystemError');
@@ -368,8 +365,8 @@ describe('ConfigurationManager - Effect-based operations', () => {
368
365
  ],
369
366
  defaultPresetId: '1',
370
367
  };
371
- configManager = new ConfigurationManager();
372
- await Effect.runPromise(configManager.setDefaultPresetEffect('2'));
368
+ configEditor.reload();
369
+ await Effect.runPromise(setDefaultPresetEffect(configEditor, '2', TEST_CONFIG_PATH));
373
370
  expect(writeFileSync).toHaveBeenCalled();
374
371
  });
375
372
  it('should fail with ValidationError when preset id does not exist', async () => {
@@ -377,8 +374,8 @@ describe('ConfigurationManager - Effect-based operations', () => {
377
374
  presets: [{ id: '1', name: 'Main', command: 'claude' }],
378
375
  defaultPresetId: '1',
379
376
  };
380
- configManager = new ConfigurationManager();
381
- const result = await Effect.runPromise(Effect.either(configManager.setDefaultPresetEffect('999')));
377
+ configEditor.reload();
378
+ const result = await Effect.runPromise(Effect.either(setDefaultPresetEffect(configEditor, '999', TEST_CONFIG_PATH)));
382
379
  expect(Either.isLeft(result)).toBe(true);
383
380
  if (Either.isLeft(result)) {
384
381
  expect(result.left._tag).toBe('ValidationError');
@@ -393,11 +390,11 @@ describe('ConfigurationManager - Effect-based operations', () => {
393
390
  ],
394
391
  defaultPresetId: '1',
395
392
  };
396
- configManager = new ConfigurationManager();
393
+ configEditor.reload();
397
394
  writeFileSync.mockImplementation(() => {
398
395
  throw new Error('Save failed');
399
396
  });
400
- const result = await Effect.runPromise(Effect.either(configManager.setDefaultPresetEffect('2')));
397
+ const result = await Effect.runPromise(Effect.either(setDefaultPresetEffect(configEditor, '2', TEST_CONFIG_PATH)));
401
398
  expect(Either.isLeft(result)).toBe(true);
402
399
  if (Either.isLeft(result)) {
403
400
  expect(result.left._tag).toBe('FileSystemError');
@@ -0,0 +1,101 @@
1
+ import { globalConfigManager } from './globalConfigManager.js';
2
+ import { projectConfigManager } from './projectConfigManager.js';
3
+ /**
4
+ * ConfigEditor provides scope-aware configuration editing.
5
+ * The scope is determined at construction time.
6
+ *
7
+ * - When scope='global', uses GlobalConfigManager singleton
8
+ * - When scope='project', uses ProjectConfigManager singleton
9
+ * (with fallback to global if project value is undefined)
10
+ *
11
+ * IMPORTANT: Uses singletons to ensure that config changes are
12
+ * immediately visible to all components (e.g., shortcutManager, configReader).
13
+ */
14
+ export class ConfigEditor {
15
+ constructor(scope) {
16
+ Object.defineProperty(this, "scope", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: void 0
21
+ });
22
+ Object.defineProperty(this, "configEditor", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: void 0
27
+ });
28
+ this.scope = scope;
29
+ this.configEditor =
30
+ scope === 'global' ? globalConfigManager : projectConfigManager;
31
+ }
32
+ // IConfigEditor implementation - delegates to configEditor with fallback to global
33
+ getShortcuts() {
34
+ return (this.configEditor.getShortcuts() ?? globalConfigManager.getShortcuts());
35
+ }
36
+ setShortcuts(value) {
37
+ this.configEditor.setShortcuts(value);
38
+ }
39
+ getStatusHooks() {
40
+ return (this.configEditor.getStatusHooks() ?? globalConfigManager.getStatusHooks());
41
+ }
42
+ setStatusHooks(value) {
43
+ this.configEditor.setStatusHooks(value);
44
+ }
45
+ getWorktreeHooks() {
46
+ return (this.configEditor.getWorktreeHooks() ??
47
+ globalConfigManager.getWorktreeHooks());
48
+ }
49
+ setWorktreeHooks(value) {
50
+ this.configEditor.setWorktreeHooks(value);
51
+ }
52
+ getWorktreeConfig() {
53
+ return (this.configEditor.getWorktreeConfig() ??
54
+ globalConfigManager.getWorktreeConfig());
55
+ }
56
+ setWorktreeConfig(value) {
57
+ this.configEditor.setWorktreeConfig(value);
58
+ }
59
+ getCommandPresets() {
60
+ return (this.configEditor.getCommandPresets() ??
61
+ globalConfigManager.getCommandPresets());
62
+ }
63
+ setCommandPresets(value) {
64
+ this.configEditor.setCommandPresets(value);
65
+ }
66
+ getAutoApprovalConfig() {
67
+ return (this.configEditor.getAutoApprovalConfig() ??
68
+ globalConfigManager.getAutoApprovalConfig());
69
+ }
70
+ setAutoApprovalConfig(value) {
71
+ this.configEditor.setAutoApprovalConfig(value);
72
+ }
73
+ reload() {
74
+ this.configEditor.reload();
75
+ }
76
+ // Helper methods
77
+ /**
78
+ * Get the current scope
79
+ */
80
+ getScope() {
81
+ return this.scope;
82
+ }
83
+ /**
84
+ * Check if project has an override for a specific field
85
+ */
86
+ hasProjectOverride(field) {
87
+ return projectConfigManager.hasOverride(field);
88
+ }
89
+ /**
90
+ * Remove project override for a specific field
91
+ */
92
+ removeProjectOverride(field) {
93
+ projectConfigManager.removeOverride(field);
94
+ }
95
+ }
96
+ /**
97
+ * Factory function to create a ConfigEditor instance
98
+ */
99
+ export function createConfigEditor(scope) {
100
+ return new ConfigEditor(scope);
101
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
- import { ConfigurationManager } from './configurationManager.js';
3
+ import { ConfigEditor } from './configEditor.js';
4
+ import { getSelectPresetOnStart, setSelectPresetOnStart } from './testUtils.js';
4
5
  // Mock fs module
5
6
  vi.mock('fs', () => ({
6
7
  existsSync: vi.fn(),
@@ -12,12 +13,14 @@ vi.mock('fs', () => ({
12
13
  vi.mock('os', () => ({
13
14
  homedir: vi.fn(() => '/home/test'),
14
15
  }));
15
- describe('ConfigurationManager - selectPresetOnStart', () => {
16
- let configManager;
16
+ describe('ConfigEditor (global scope) - selectPresetOnStart', () => {
17
+ let configEditor;
17
18
  let mockConfigData;
19
+ let savedConfigData = null;
18
20
  beforeEach(() => {
19
21
  // Reset all mocks
20
22
  vi.clearAllMocks();
23
+ savedConfigData = null;
21
24
  // Default mock config data
22
25
  mockConfigData = {
23
26
  shortcuts: {
@@ -46,55 +49,60 @@ describe('ConfigurationManager - selectPresetOnStart', () => {
46
49
  return path.includes('config.json');
47
50
  });
48
51
  readFileSync.mockImplementation(() => {
49
- return JSON.stringify(mockConfigData);
52
+ // Return saved data if available, otherwise return initial mock data
53
+ return savedConfigData ?? JSON.stringify(mockConfigData);
50
54
  });
51
55
  mkdirSync.mockImplementation(() => { });
52
- writeFileSync.mockImplementation(() => { });
53
- // Create new instance for each test
54
- configManager = new ConfigurationManager();
56
+ writeFileSync.mockImplementation((_path, data) => {
57
+ // Track written data so subsequent reads return it
58
+ savedConfigData = data;
59
+ });
60
+ // Create new instance for each test and reload to pick up mocked fs
61
+ configEditor = new ConfigEditor('global');
62
+ configEditor.reload();
55
63
  });
56
64
  afterEach(() => {
57
65
  vi.resetAllMocks();
58
66
  });
59
67
  describe('getSelectPresetOnStart', () => {
60
68
  it('should return false by default', () => {
61
- const result = configManager.getSelectPresetOnStart();
69
+ const result = getSelectPresetOnStart(configEditor);
62
70
  expect(result).toBe(false);
63
71
  });
64
72
  it('should return true when configured', () => {
65
73
  mockConfigData.commandPresets.selectPresetOnStart = true;
66
- configManager = new ConfigurationManager();
67
- const result = configManager.getSelectPresetOnStart();
74
+ configEditor.reload();
75
+ const result = getSelectPresetOnStart(configEditor);
68
76
  expect(result).toBe(true);
69
77
  });
70
78
  it('should return false when explicitly set to false', () => {
71
79
  mockConfigData.commandPresets.selectPresetOnStart = false;
72
- configManager = new ConfigurationManager();
73
- const result = configManager.getSelectPresetOnStart();
80
+ configEditor.reload();
81
+ const result = getSelectPresetOnStart(configEditor);
74
82
  expect(result).toBe(false);
75
83
  });
76
84
  });
77
85
  describe('setSelectPresetOnStart', () => {
78
86
  it('should set selectPresetOnStart to true', () => {
79
- configManager.setSelectPresetOnStart(true);
80
- const result = configManager.getSelectPresetOnStart();
87
+ setSelectPresetOnStart(configEditor, true);
88
+ const result = getSelectPresetOnStart(configEditor);
81
89
  expect(result).toBe(true);
82
90
  // Verify that config was saved
83
91
  expect(writeFileSync).toHaveBeenCalledWith(expect.stringContaining('config.json'), expect.stringContaining('"selectPresetOnStart": true'));
84
92
  });
85
93
  it('should set selectPresetOnStart to false', () => {
86
94
  // First set to true
87
- configManager.setSelectPresetOnStart(true);
95
+ setSelectPresetOnStart(configEditor, true);
88
96
  // Then set to false
89
- configManager.setSelectPresetOnStart(false);
90
- const result = configManager.getSelectPresetOnStart();
97
+ setSelectPresetOnStart(configEditor, false);
98
+ const result = getSelectPresetOnStart(configEditor);
91
99
  expect(result).toBe(false);
92
100
  // Verify that config was saved
93
101
  expect(writeFileSync).toHaveBeenLastCalledWith(expect.stringContaining('config.json'), expect.stringContaining('"selectPresetOnStart": false'));
94
102
  });
95
103
  it('should preserve other preset configuration when setting selectPresetOnStart', () => {
96
- configManager.setSelectPresetOnStart(true);
97
- const presets = configManager.getCommandPresets();
104
+ setSelectPresetOnStart(configEditor, true);
105
+ const presets = configEditor.getCommandPresets();
98
106
  expect(presets.presets).toHaveLength(2);
99
107
  expect(presets.defaultPresetId).toBe('1');
100
108
  expect(presets.selectPresetOnStart).toBe(true);
@@ -0,0 +1 @@
1
+ export {};