ccmanager 3.4.0 → 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 (65) 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/NewWorktree.js +2 -2
  17. package/dist/components/NewWorktree.test.js +6 -6
  18. package/dist/components/PresetSelector.js +2 -2
  19. package/dist/contexts/ConfigEditorContext.d.ts +21 -0
  20. package/dist/contexts/ConfigEditorContext.js +25 -0
  21. package/dist/services/autoApprovalVerifier.js +3 -3
  22. package/dist/services/autoApprovalVerifier.test.js +2 -2
  23. package/dist/services/config/configEditor.d.ts +46 -0
  24. package/dist/services/{configurationManager.effect.test.js → config/configEditor.effect.test.js} +46 -49
  25. package/dist/services/config/configEditor.js +101 -0
  26. package/dist/services/{configurationManager.selectPresetOnStart.test.js → config/configEditor.selectPresetOnStart.test.js} +27 -19
  27. package/dist/services/{configurationManager.test.js → config/configEditor.test.js} +60 -132
  28. package/dist/services/config/configReader.d.ts +28 -0
  29. package/dist/services/config/configReader.js +95 -0
  30. package/dist/services/config/configReader.multiProject.test.d.ts +1 -0
  31. package/dist/services/config/configReader.multiProject.test.js +136 -0
  32. package/dist/services/config/globalConfigManager.d.ts +30 -0
  33. package/dist/services/config/globalConfigManager.js +216 -0
  34. package/dist/services/config/index.d.ts +13 -0
  35. package/dist/services/config/index.js +13 -0
  36. package/dist/services/config/projectConfigManager.d.ts +41 -0
  37. package/dist/services/config/projectConfigManager.js +181 -0
  38. package/dist/services/config/projectConfigManager.test.d.ts +1 -0
  39. package/dist/services/config/projectConfigManager.test.js +105 -0
  40. package/dist/services/config/testUtils.d.ts +81 -0
  41. package/dist/services/config/testUtils.js +351 -0
  42. package/dist/services/sessionManager.autoApproval.test.js +5 -5
  43. package/dist/services/sessionManager.effect.test.js +27 -18
  44. package/dist/services/sessionManager.js +17 -34
  45. package/dist/services/sessionManager.statePersistence.test.js +5 -4
  46. package/dist/services/sessionManager.test.js +52 -47
  47. package/dist/services/shortcutManager.d.ts +0 -1
  48. package/dist/services/shortcutManager.js +5 -16
  49. package/dist/services/shortcutManager.test.js +2 -2
  50. package/dist/services/worktreeService.d.ts +12 -0
  51. package/dist/services/worktreeService.js +24 -4
  52. package/dist/services/worktreeService.sort.test.js +105 -109
  53. package/dist/services/worktreeService.test.js +5 -5
  54. package/dist/types/index.d.ts +41 -7
  55. package/dist/utils/gitUtils.d.ts +8 -0
  56. package/dist/utils/gitUtils.js +32 -0
  57. package/dist/utils/hookExecutor.js +2 -2
  58. package/dist/utils/hookExecutor.test.js +8 -12
  59. package/dist/utils/worktreeUtils.test.js +0 -1
  60. package/package.json +7 -7
  61. package/dist/services/configurationManager.d.ts +0 -121
  62. package/dist/services/configurationManager.js +0 -597
  63. /package/dist/services/{configurationManager.effect.test.d.ts → config/configEditor.effect.test.d.ts} +0 -0
  64. /package/dist/services/{configurationManager.selectPresetOnStart.test.d.ts → config/configEditor.selectPresetOnStart.test.d.ts} +0 -0
  65. /package/dist/services/{configurationManager.test.d.ts → config/configEditor.test.d.ts} +0 -0
@@ -0,0 +1,351 @@
1
+ /**
2
+ * @fileoverview Test utilities for config module
3
+ *
4
+ * WARNING: This file is intended for TEST USE ONLY.
5
+ * Do not import from production code.
6
+ *
7
+ * These functions provide Effect-based wrappers for testing config operations.
8
+ */
9
+ import { Effect, Either } from 'effect';
10
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
11
+ import { DEFAULT_SHORTCUTS, } from '../../types/index.js';
12
+ import { FileSystemError, ConfigError, ValidationError, } from '../../types/errors.js';
13
+ /**
14
+ * TEST ONLY: Load configuration from file with Effect-based error handling
15
+ *
16
+ * @param configPath - Path to the config file
17
+ * @param legacyShortcutsPath - Path to legacy shortcuts file for migration
18
+ * @returns Effect with ConfigurationData on success, errors on failure
19
+ */
20
+ export function loadConfigEffect(configPath, legacyShortcutsPath) {
21
+ return Effect.try({
22
+ try: () => {
23
+ if (existsSync(configPath)) {
24
+ const configData = readFileSync(configPath, 'utf-8');
25
+ const parsedConfig = JSON.parse(configData);
26
+ return applyDefaults(parsedConfig);
27
+ }
28
+ else {
29
+ const migratedConfig = migrateLegacyShortcutsSync(configPath, legacyShortcutsPath);
30
+ return applyDefaults(migratedConfig || {});
31
+ }
32
+ },
33
+ catch: (error) => {
34
+ if (error instanceof SyntaxError) {
35
+ return new ConfigError({
36
+ configPath,
37
+ reason: 'parse',
38
+ details: String(error),
39
+ });
40
+ }
41
+ return new FileSystemError({
42
+ operation: 'read',
43
+ path: configPath,
44
+ cause: String(error),
45
+ });
46
+ },
47
+ });
48
+ }
49
+ /**
50
+ * Type guard to check if value is a non-null object
51
+ */
52
+ function isObject(value) {
53
+ return value !== null && typeof value === 'object';
54
+ }
55
+ function validationError(constraint, receivedValue) {
56
+ return Either.left(new ValidationError({
57
+ field: 'config',
58
+ constraint,
59
+ receivedValue,
60
+ }));
61
+ }
62
+ function validationSuccess(config) {
63
+ return Either.right(config);
64
+ }
65
+ /**
66
+ * TEST ONLY: Validate configuration structure
67
+ */
68
+ export function validateConfig(config) {
69
+ if (!isObject(config)) {
70
+ return validationError('must be a valid configuration object', config);
71
+ }
72
+ const shortcuts = config['shortcuts'];
73
+ if (shortcuts !== undefined && !isObject(shortcuts)) {
74
+ return validationError('shortcuts must be a valid object', config);
75
+ }
76
+ return validationSuccess(config);
77
+ }
78
+ /**
79
+ * Apply default values to configuration
80
+ */
81
+ function applyDefaults(config) {
82
+ if (!config.shortcuts) {
83
+ config.shortcuts = DEFAULT_SHORTCUTS;
84
+ }
85
+ if (!config.statusHooks) {
86
+ config.statusHooks = {};
87
+ }
88
+ if (!config.worktreeHooks) {
89
+ config.worktreeHooks = {};
90
+ }
91
+ if (!config.worktree) {
92
+ config.worktree = {
93
+ autoDirectory: false,
94
+ copySessionData: true,
95
+ sortByLastSession: false,
96
+ };
97
+ }
98
+ if (!Object.prototype.hasOwnProperty.call(config.worktree, 'copySessionData')) {
99
+ config.worktree.copySessionData = true;
100
+ }
101
+ if (!Object.prototype.hasOwnProperty.call(config.worktree, 'sortByLastSession')) {
102
+ config.worktree.sortByLastSession = false;
103
+ }
104
+ if (!config.autoApproval) {
105
+ config.autoApproval = {
106
+ enabled: false,
107
+ timeout: 30,
108
+ };
109
+ }
110
+ else {
111
+ if (!Object.prototype.hasOwnProperty.call(config.autoApproval, 'enabled')) {
112
+ config.autoApproval.enabled = false;
113
+ }
114
+ if (!Object.prototype.hasOwnProperty.call(config.autoApproval, 'timeout')) {
115
+ config.autoApproval.timeout = 30;
116
+ }
117
+ }
118
+ return config;
119
+ }
120
+ /**
121
+ * Synchronous legacy shortcuts migration helper
122
+ */
123
+ function migrateLegacyShortcutsSync(configPath, legacyShortcutsPath) {
124
+ if (existsSync(legacyShortcutsPath)) {
125
+ try {
126
+ const shortcutsData = readFileSync(legacyShortcutsPath, 'utf-8');
127
+ const shortcuts = JSON.parse(shortcutsData);
128
+ if (shortcuts && typeof shortcuts === 'object') {
129
+ const config = { shortcuts };
130
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
131
+ console.log('Migrated shortcuts from legacy shortcuts.json to config.json');
132
+ return config;
133
+ }
134
+ }
135
+ catch (error) {
136
+ console.error('Failed to migrate legacy shortcuts:', error);
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ // ============================================================================
142
+ // Test-only helper functions for GlobalConfigManager
143
+ // These functions were moved from GlobalConfigManager class to reduce its API
144
+ // surface while keeping tests functional.
145
+ // ============================================================================
146
+ /**
147
+ * TEST ONLY: Add or update a preset in the config manager
148
+ */
149
+ export function addPreset(configManager, preset) {
150
+ const presets = configManager.getCommandPresets();
151
+ // Replace if exists, otherwise add
152
+ const existingIndex = presets.presets.findIndex(p => p.id === preset.id);
153
+ if (existingIndex >= 0) {
154
+ presets.presets[existingIndex] = preset;
155
+ }
156
+ else {
157
+ presets.presets.push(preset);
158
+ }
159
+ configManager.setCommandPresets(presets);
160
+ }
161
+ /**
162
+ * TEST ONLY: Delete a preset by ID
163
+ */
164
+ export function deletePreset(configManager, id) {
165
+ const presets = configManager.getCommandPresets();
166
+ // Don't delete if it's the last preset
167
+ if (presets.presets.length <= 1) {
168
+ return;
169
+ }
170
+ // Remove the preset
171
+ presets.presets = presets.presets.filter(p => p.id !== id);
172
+ // Update default if needed
173
+ if (presets.defaultPresetId === id && presets.presets.length > 0) {
174
+ presets.defaultPresetId = presets.presets[0].id;
175
+ }
176
+ configManager.setCommandPresets(presets);
177
+ }
178
+ /**
179
+ * TEST ONLY: Set the default preset ID
180
+ */
181
+ export function setDefaultPreset(configManager, id) {
182
+ const presets = configManager.getCommandPresets();
183
+ // Only update if preset exists
184
+ if (presets.presets.some(p => p.id === id)) {
185
+ presets.defaultPresetId = id;
186
+ configManager.setCommandPresets(presets);
187
+ }
188
+ }
189
+ /**
190
+ * TEST ONLY: Save configuration to file with Effect-based error handling
191
+ */
192
+ export function saveConfigEffect(configManager, config, configPath) {
193
+ return Effect.try({
194
+ try: () => {
195
+ configManager.setCommandPresets(config.commandPresets || configManager.getCommandPresets());
196
+ if (config.shortcuts) {
197
+ configManager.setShortcuts(config.shortcuts);
198
+ }
199
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
200
+ },
201
+ catch: (error) => {
202
+ return new FileSystemError({
203
+ operation: 'write',
204
+ path: configPath,
205
+ cause: String(error),
206
+ });
207
+ },
208
+ });
209
+ }
210
+ /**
211
+ * TEST ONLY: Set shortcuts with Effect-based error handling
212
+ */
213
+ export function setShortcutsEffect(configManager, shortcuts, configPath) {
214
+ return Effect.try({
215
+ try: () => {
216
+ configManager.setShortcuts(shortcuts);
217
+ const config = {
218
+ ...{},
219
+ shortcuts,
220
+ commandPresets: configManager.getCommandPresets(),
221
+ };
222
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
223
+ },
224
+ catch: (error) => {
225
+ return new FileSystemError({
226
+ operation: 'write',
227
+ path: configPath,
228
+ cause: String(error),
229
+ });
230
+ },
231
+ });
232
+ }
233
+ /**
234
+ * TEST ONLY: Set command presets with Effect-based error handling
235
+ */
236
+ export function setCommandPresetsEffect(configManager, presets, configPath) {
237
+ return Effect.try({
238
+ try: () => {
239
+ configManager.setCommandPresets(presets);
240
+ const config = {
241
+ ...{},
242
+ commandPresets: presets,
243
+ };
244
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
245
+ },
246
+ catch: (error) => {
247
+ return new FileSystemError({
248
+ operation: 'write',
249
+ path: configPath,
250
+ cause: String(error),
251
+ });
252
+ },
253
+ });
254
+ }
255
+ /**
256
+ * TEST ONLY: Add or update preset with Effect-based error handling
257
+ */
258
+ export function addPresetEffect(configManager, preset, configPath) {
259
+ const presets = configManager.getCommandPresets();
260
+ // Replace if exists, otherwise add
261
+ const existingIndex = presets.presets.findIndex(p => p.id === preset.id);
262
+ if (existingIndex >= 0) {
263
+ presets.presets[existingIndex] = preset;
264
+ }
265
+ else {
266
+ presets.presets.push(preset);
267
+ }
268
+ return setCommandPresetsEffect(configManager, presets, configPath);
269
+ }
270
+ /**
271
+ * TEST ONLY: Delete preset with Effect-based error handling
272
+ */
273
+ export function deletePresetEffect(configManager, id, configPath) {
274
+ const presets = configManager.getCommandPresets();
275
+ // Don't delete if it's the last preset
276
+ if (presets.presets.length <= 1) {
277
+ return Effect.fail(new ValidationError({
278
+ field: 'presetId',
279
+ constraint: 'Cannot delete last preset',
280
+ receivedValue: id,
281
+ }));
282
+ }
283
+ // Remove the preset
284
+ presets.presets = presets.presets.filter(p => p.id !== id);
285
+ // Update default if needed
286
+ if (presets.defaultPresetId === id && presets.presets.length > 0) {
287
+ presets.defaultPresetId = presets.presets[0].id;
288
+ }
289
+ return setCommandPresetsEffect(configManager, presets, configPath);
290
+ }
291
+ /**
292
+ * TEST ONLY: Set default preset with Effect-based error handling
293
+ */
294
+ export function setDefaultPresetEffect(configManager, id, configPath) {
295
+ const presets = configManager.getCommandPresets();
296
+ // Only update if preset exists
297
+ if (!presets.presets.some(p => p.id === id)) {
298
+ return Effect.fail(new ValidationError({
299
+ field: 'presetId',
300
+ constraint: 'Preset not found',
301
+ receivedValue: id,
302
+ }));
303
+ }
304
+ presets.defaultPresetId = id;
305
+ return setCommandPresetsEffect(configManager, presets, configPath);
306
+ }
307
+ /**
308
+ * TEST ONLY: Get the default preset
309
+ */
310
+ export function getDefaultPreset(configManager) {
311
+ const presets = configManager.getCommandPresets();
312
+ const defaultPreset = presets.presets.find(p => p.id === presets.defaultPresetId);
313
+ return defaultPreset || presets.presets[0];
314
+ }
315
+ /**
316
+ * TEST ONLY: Get whether to select preset on start
317
+ */
318
+ export function getSelectPresetOnStart(configManager) {
319
+ const presets = configManager.getCommandPresets();
320
+ return presets.selectPresetOnStart ?? false;
321
+ }
322
+ /**
323
+ * TEST ONLY: Set whether to select preset on start
324
+ */
325
+ export function setSelectPresetOnStart(configManager, enabled) {
326
+ const presets = configManager.getCommandPresets();
327
+ presets.selectPresetOnStart = enabled;
328
+ configManager.setCommandPresets(presets);
329
+ }
330
+ /**
331
+ * TEST ONLY: Get whether auto-approval is enabled
332
+ */
333
+ export function isAutoApprovalEnabled(configManager) {
334
+ const config = configManager.getAutoApprovalConfig();
335
+ return config?.enabled ?? false;
336
+ }
337
+ /**
338
+ * TEST ONLY: Get preset by ID with Either-based error handling
339
+ */
340
+ export function getPresetByIdEffect(configManager, id) {
341
+ const presets = configManager.getCommandPresets();
342
+ const preset = presets.presets.find(p => p.id === id);
343
+ if (!preset) {
344
+ return Either.left(new ValidationError({
345
+ field: 'presetId',
346
+ constraint: 'Preset not found',
347
+ receivedValue: id,
348
+ }));
349
+ }
350
+ return Either.right(preset);
351
+ }
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { EventEmitter } from 'events';
3
3
  import { spawn } from './bunTerminal.js';
4
4
  import { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
5
- import { Effect } from 'effect';
5
+ import { Effect, Either } from 'effect';
6
6
  const detectStateMock = vi.fn();
7
7
  // Create a deferred promise pattern for controllable mock
8
8
  let verifyResolve = null;
@@ -20,8 +20,8 @@ vi.mock('./stateDetector/index.js', () => ({
20
20
  detectBackgroundTask: () => false,
21
21
  }),
22
22
  }));
23
- vi.mock('./configurationManager.js', () => ({
24
- configurationManager: {
23
+ vi.mock('./config/configReader.js', () => ({
24
+ configReader: {
25
25
  getConfig: vi.fn().mockReturnValue({
26
26
  commands: [
27
27
  {
@@ -33,12 +33,12 @@ vi.mock('./configurationManager.js', () => ({
33
33
  ],
34
34
  defaultCommandId: 'test',
35
35
  }),
36
- getPresetById: vi.fn().mockReturnValue({
36
+ getPresetByIdEffect: vi.fn().mockReturnValue(Either.right({
37
37
  id: 'test',
38
38
  name: 'Test',
39
39
  command: 'test',
40
40
  args: [],
41
- }),
41
+ })),
42
42
  getDefaultPreset: vi.fn().mockReturnValue({
43
43
  id: 'test',
44
44
  name: 'Test',
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { Effect, Either } from 'effect';
3
3
  import { spawn } from './bunTerminal.js';
4
4
  import { EventEmitter } from 'events';
5
+ import { ValidationError } from '../types/errors.js';
5
6
  // Mock bunTerminal
6
7
  vi.mock('./bunTerminal.js', () => ({
7
8
  spawn: vi.fn(function () {
@@ -14,10 +15,10 @@ vi.mock('child_process', () => ({
14
15
  execFile: vi.fn(),
15
16
  }));
16
17
  // Mock configuration manager
17
- vi.mock('./configurationManager.js', () => ({
18
- configurationManager: {
18
+ vi.mock('./config/configReader.js', () => ({
19
+ configReader: {
19
20
  getDefaultPreset: vi.fn(),
20
- getPresetById: vi.fn(),
21
+ getPresetByIdEffect: vi.fn(),
21
22
  setWorktreeLastOpened: vi.fn(),
22
23
  getWorktreeLastOpenedTime: vi.fn(),
23
24
  getWorktreeLastOpened: vi.fn(() => ({})),
@@ -83,14 +84,14 @@ describe('SessionManager Effect-based Operations', () => {
83
84
  let sessionManager;
84
85
  let mockPty;
85
86
  let SessionManager;
86
- let configurationManager;
87
+ let configReader;
87
88
  beforeEach(async () => {
88
89
  vi.clearAllMocks();
89
90
  // Dynamically import after mocks are set up
90
91
  const sessionManagerModule = await import('./sessionManager.js');
91
- const configManagerModule = await import('./configurationManager.js');
92
+ const configManagerModule = await import('./config/configReader.js');
92
93
  SessionManager = sessionManagerModule.SessionManager;
93
- configurationManager = configManagerModule.configurationManager;
94
+ configReader = configManagerModule.configReader;
94
95
  sessionManager = new SessionManager();
95
96
  mockPty = new MockPty();
96
97
  });
@@ -100,7 +101,7 @@ describe('SessionManager Effect-based Operations', () => {
100
101
  describe('createSessionWithPreset returning Effect', () => {
101
102
  it('should return Effect that succeeds with Session', async () => {
102
103
  // Setup mock preset
103
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
104
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
104
105
  id: '1',
105
106
  name: 'Main',
106
107
  command: 'claude',
@@ -117,9 +118,13 @@ describe('SessionManager Effect-based Operations', () => {
117
118
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
118
119
  });
119
120
  it('should return Effect that fails with ConfigError when preset not found', async () => {
120
- // Setup mocks - both return null/undefined
121
- vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
122
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue(undefined);
121
+ // Setup mocks - getPresetByIdEffect returns Left, getDefaultPreset returns undefined
122
+ vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
123
+ field: 'presetId',
124
+ constraint: 'Preset not found',
125
+ receivedValue: 'invalid-preset',
126
+ })));
127
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue(undefined);
123
128
  // Create session with non-existent preset - should return Effect
124
129
  const effect = sessionManager.createSessionWithPresetEffect('/test/worktree', 'invalid-preset');
125
130
  // Execute the Effect and expect it to fail with ConfigError
@@ -135,7 +140,7 @@ describe('SessionManager Effect-based Operations', () => {
135
140
  });
136
141
  it('should return Effect that fails with ProcessError when spawn fails', async () => {
137
142
  // Setup mock preset
138
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
143
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
139
144
  id: '1',
140
145
  name: 'Main',
141
146
  command: 'invalid-command',
@@ -160,7 +165,7 @@ describe('SessionManager Effect-based Operations', () => {
160
165
  });
161
166
  it('should return existing session without creating new Effect', async () => {
162
167
  // Setup mock preset
163
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
168
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
164
169
  id: '1',
165
170
  name: 'Main',
166
171
  command: 'claude',
@@ -181,7 +186,7 @@ describe('SessionManager Effect-based Operations', () => {
181
186
  describe('createSessionWithDevcontainer returning Effect', () => {
182
187
  it('should return Effect that succeeds with Session', async () => {
183
188
  // Setup mock preset
184
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
189
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
185
190
  id: '1',
186
191
  name: 'Main',
187
192
  command: 'claude',
@@ -244,9 +249,13 @@ describe('SessionManager Effect-based Operations', () => {
244
249
  }
245
250
  });
246
251
  it('should return Effect that fails with ConfigError when preset not found', async () => {
247
- // Setup mocks - both return null/undefined
248
- vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
249
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue(undefined);
252
+ // Setup mocks - getPresetByIdEffect returns Left, getDefaultPreset returns undefined
253
+ vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
254
+ field: 'presetId',
255
+ constraint: 'Preset not found',
256
+ receivedValue: 'invalid-preset',
257
+ })));
258
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue(undefined);
250
259
  // Mock exec to succeed (devcontainer up)
251
260
  const { exec } = await import('child_process');
252
261
  const mockExec = vi.mocked(exec);
@@ -276,7 +285,7 @@ describe('SessionManager Effect-based Operations', () => {
276
285
  describe('terminateSession returning Effect', () => {
277
286
  it('should return Effect that succeeds when session exists', async () => {
278
287
  // Setup mock preset and create a session first
279
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
288
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
280
289
  id: '1',
281
290
  name: 'Main',
282
291
  command: 'claude',
@@ -305,7 +314,7 @@ describe('SessionManager Effect-based Operations', () => {
305
314
  });
306
315
  it('should return Effect that succeeds even when process kill fails', async () => {
307
316
  // Setup mock preset and create a session
308
- vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
317
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
309
318
  id: '1',
310
319
  name: 'Main',
311
320
  command: 'claude',
@@ -3,11 +3,12 @@ import { EventEmitter } from 'events';
3
3
  import pkg from '@xterm/headless';
4
4
  import { exec } from 'child_process';
5
5
  import { promisify } from 'util';
6
- import { configurationManager } from './configurationManager.js';
6
+ import { configReader } from './config/configReader.js';
7
+ import { setWorktreeLastOpened } from './worktreeService.js';
7
8
  import { executeStatusHook } from '../utils/hookExecutor.js';
8
9
  import { createStateDetector } from './stateDetector/index.js';
9
10
  import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
10
- import { Effect } from 'effect';
11
+ import { Effect, Either } from 'effect';
11
12
  import { ProcessError, ConfigError } from '../types/errors.js';
12
13
  import { autoApprovalVerifier } from './autoApprovalVerifier.js';
13
14
  import { logger } from '../utils/logger.js';
@@ -33,7 +34,7 @@ export class SessionManager extends EventEmitter {
33
34
  const detectedState = session.stateDetector.detectState(session.terminal, stateData.state);
34
35
  // If auto-approval is enabled and state is waiting_input, convert to pending_auto_approval
35
36
  if (detectedState === 'waiting_input' &&
36
- configurationManager.isAutoApprovalEnabled() &&
37
+ configReader.isAutoApprovalEnabled() &&
37
38
  !stateData.autoApprovalFailed) {
38
39
  return 'pending_auto_approval';
39
40
  }
@@ -188,7 +189,7 @@ export class SessionManager extends EventEmitter {
188
189
  logLevel: 'off',
189
190
  });
190
191
  }
191
- async createSessionInternal(worktreePath, ptyProcess, commandConfig, options = {}) {
192
+ async createSessionInternal(worktreePath, ptyProcess, options = {}) {
192
193
  const id = this.createSessionId();
193
194
  const terminal = this.createTerminal();
194
195
  const detectionStrategy = options.detectionStrategy ?? 'claude';
@@ -204,7 +205,6 @@ export class SessionManager extends EventEmitter {
204
205
  terminal,
205
206
  stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
206
207
  isPrimaryCommand: options.isPrimaryCommand ?? true,
207
- commandConfig,
208
208
  detectionStrategy,
209
209
  devcontainerConfig: options.devcontainerConfig ?? undefined,
210
210
  stateMutex: new Mutex(createInitialSessionStateData()),
@@ -214,7 +214,7 @@ export class SessionManager extends EventEmitter {
214
214
  this.setupBackgroundHandler(session);
215
215
  this.sessions.set(worktreePath, session);
216
216
  // Record the timestamp when this worktree was opened
217
- configurationManager.setWorktreeLastOpened(worktreePath, Date.now());
217
+ setWorktreeLastOpened(worktreePath, Date.now());
218
218
  this.emit('sessionCreated', session);
219
219
  return session;
220
220
  }
@@ -244,12 +244,12 @@ export class SessionManager extends EventEmitter {
244
244
  if (existing) {
245
245
  return existing;
246
246
  }
247
- // Get preset configuration
247
+ // Get preset configuration using Either-based lookup
248
248
  let preset = presetId
249
- ? configurationManager.getPresetById(presetId)
249
+ ? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
250
250
  : null;
251
251
  if (!preset) {
252
- preset = configurationManager.getDefaultPreset();
252
+ preset = configReader.getDefaultPreset();
253
253
  }
254
254
  // Validate preset exists
255
255
  if (!preset) {
@@ -263,14 +263,9 @@ export class SessionManager extends EventEmitter {
263
263
  }
264
264
  const command = preset.command;
265
265
  const args = preset.args || [];
266
- const commandConfig = {
267
- command: preset.command,
268
- args: preset.args,
269
- fallbackArgs: preset.fallbackArgs,
270
- };
271
266
  // Spawn the process - fallback will be handled by setupExitHandler
272
267
  const ptyProcess = await this.spawn(command, args, worktreePath);
273
- return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
268
+ return this.createSessionInternal(worktreePath, ptyProcess, {
274
269
  isPrimaryCommand: true,
275
270
  detectionStrategy: preset.detectionStrategy,
276
271
  });
@@ -329,8 +324,6 @@ export class SessionManager extends EventEmitter {
329
324
  if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
330
325
  try {
331
326
  let fallbackProcess;
332
- // Use fallback args if available, otherwise use empty args
333
- const fallbackArgs = session.commandConfig?.fallbackArgs || [];
334
327
  // Check if we're in a devcontainer session
335
328
  if (session.devcontainerConfig) {
336
329
  // Parse the exec command to extract arguments
@@ -338,17 +331,12 @@ export class SessionManager extends EventEmitter {
338
331
  const devcontainerCmd = execParts[0] || 'devcontainer';
339
332
  const execArgs = execParts.slice(1);
340
333
  // Build fallback command for devcontainer
341
- const fallbackFullArgs = [
342
- ...execArgs,
343
- '--',
344
- session.commandConfig?.command || 'claude',
345
- ...fallbackArgs,
346
- ];
334
+ const fallbackFullArgs = [...execArgs, '--', 'claude'];
347
335
  fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath);
348
336
  }
349
337
  else {
350
338
  // Regular fallback without devcontainer
351
- fallbackProcess = await this.spawn(session.commandConfig?.command || 'claude', fallbackArgs, session.worktreePath);
339
+ fallbackProcess = await this.spawn('claude', [], session.worktreePath);
352
340
  }
353
341
  // Replace the process
354
342
  session.process = fallbackProcess;
@@ -466,7 +454,7 @@ export class SessionManager extends EventEmitter {
466
454
  session.isActive = active;
467
455
  // If becoming active, record the timestamp when this worktree was opened
468
456
  if (active) {
469
- configurationManager.setWorktreeLastOpened(worktreePath, Date.now());
457
+ setWorktreeLastOpened(worktreePath, Date.now());
470
458
  // Emit a restore event with the output history if available
471
459
  if (session.outputHistory.length > 0) {
472
460
  this.emit('sessionRestore', session);
@@ -620,12 +608,12 @@ export class SessionManager extends EventEmitter {
620
608
  message: `Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`,
621
609
  });
622
610
  }
623
- // Get preset configuration
611
+ // Get preset configuration using Either-based lookup
624
612
  let preset = presetId
625
- ? configurationManager.getPresetById(presetId)
613
+ ? Either.getOrElse(configReader.getPresetByIdEffect(presetId), () => null)
626
614
  : null;
627
615
  if (!preset) {
628
- preset = configurationManager.getDefaultPreset();
616
+ preset = configReader.getDefaultPreset();
629
617
  }
630
618
  // Validate preset exists
631
619
  if (!preset) {
@@ -650,12 +638,7 @@ export class SessionManager extends EventEmitter {
650
638
  ];
651
639
  // Spawn the process within devcontainer
652
640
  const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
653
- const commandConfig = {
654
- command: preset.command,
655
- args: preset.args,
656
- fallbackArgs: preset.fallbackArgs,
657
- };
658
- return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
641
+ return this.createSessionInternal(worktreePath, ptyProcess, {
659
642
  isPrimaryCommand: true,
660
643
  detectionStrategy: preset.detectionStrategy,
661
644
  devcontainerConfig,