Skip to content

Commit 84d73d1

Browse files
committed
feat: implement hierarchical configuration system
Implements a hierarchical configuration system for the MyCoder CLI with 4 levels of precedence: 1. CLI options (highest precedence) 2. Project-level config (.mycoder/config.json in current directory) 3. Global config (~/.mycoder/config.json) 4. Default values (lowest precedence) - Enhanced settings.ts to support both global and project-level config directories - Refactored config.ts to implement hierarchical configuration merging - Updated the config command to support the --global / -g flag - Added enhanced display of config sources in the list and get commands - Added error handling for cases where project directory is not writable Closes #153
1 parent 3ddc783 commit 84d73d1

File tree

6 files changed

+266
-78
lines changed

6 files changed

+266
-78
lines changed

CHANGELOG.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
# [0.8.0](https://github.com/drivecore/mycoder/compare/v0.7.0...v0.8.0) (2025-03-11)
22

3-
43
### Features
54

6-
* add --githubMode and --userPrompt as boolean CLI options that override config settings ([0390f94](https://github.com/drivecore/mycoder/commit/0390f94651e40de93a8cb9486a056a0b9cb2e165))
7-
* remove modelProvider and modelName - instant decrepation ([59834dc](https://github.com/drivecore/mycoder/commit/59834dcf932051a5c75624bd6f6ab12254f43769))
5+
- add --githubMode and --userPrompt as boolean CLI options that override config settings ([0390f94](https://github.com/drivecore/mycoder/commit/0390f94651e40de93a8cb9486a056a0b9cb2e165))
6+
- implement hierarchical configuration system with project and global levels ([#153](https://github.com/drivecore/mycoder/issues/153))
7+
- remove modelProvider and modelName - instant decrepation ([59834dc](https://github.com/drivecore/mycoder/commit/59834dcf932051a5c75624bd6f6ab12254f43769))
88

99
# [0.7.0](https://github.com/drivecore/mycoder/compare/v0.6.1...v0.7.0) (2025-03-10)
1010

11-
1211
### Bug Fixes
1312

14-
* change where anthropic key is declared ([f6f72d3](https://github.com/drivecore/mycoder/commit/f6f72d3bc18a65fc775151cd375398aba230a06f))
15-
* ensure npm publish only happens on release branch ([ec352d6](https://github.com/drivecore/mycoder/commit/ec352d6956c717726ef388a07d88372c12b634a6))
16-
13+
- change where anthropic key is declared ([f6f72d3](https://github.com/drivecore/mycoder/commit/f6f72d3bc18a65fc775151cd375398aba230a06f))
14+
- ensure npm publish only happens on release branch ([ec352d6](https://github.com/drivecore/mycoder/commit/ec352d6956c717726ef388a07d88372c12b634a6))
1715

1816
### Features
1917

20-
* add GitHub Action for issue comment commands ([136950f](https://github.com/drivecore/mycoder/commit/136950f4bd6d14e544bbd415ed313f7842a9b9a2)), closes [#162](https://github.com/drivecore/mycoder/issues/162)
21-
* allow for generic /mycoder commands ([4b6608e](https://github.com/drivecore/mycoder/commit/4b6608e0b8e5f408eb5f12fe891657a5fb25bdb4))
22-
* **release:** implement conventional commits approach ([5878dd1](https://github.com/drivecore/mycoder/commit/5878dd1a56004eb8a994d40416d759553b022eb8)), closes [#140](https://github.com/drivecore/mycoder/issues/140)
18+
- add GitHub Action for issue comment commands ([136950f](https://github.com/drivecore/mycoder/commit/136950f4bd6d14e544bbd415ed313f7842a9b9a2)), closes [#162](https://github.com/drivecore/mycoder/issues/162)
19+
- allow for generic /mycoder commands ([4b6608e](https://github.com/drivecore/mycoder/commit/4b6608e0b8e5f408eb5f12fe891657a5fb25bdb4))
20+
- **release:** implement conventional commits approach ([5878dd1](https://github.com/drivecore/mycoder/commit/5878dd1a56004eb8a994d40416d759553b022eb8)), closes [#140](https://github.com/drivecore/mycoder/issues/140)

PR_DESCRIPTION.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Implement Hierarchical Configuration System
2+
3+
This PR implements a hierarchical configuration system for the mycoder CLI with 4 levels of precedence:
4+
5+
1. CLI options (highest precedence)
6+
2. Project-level config (.mycoder/config.json in current directory)
7+
3. Global config (~/.mycoder/config.json)
8+
4. Default values (lowest precedence)
9+
10+
## Changes
11+
12+
- Enhanced `settings.ts` to support both global and project-level config directories
13+
- Refactored `config.ts` to implement hierarchical configuration merging
14+
- Updated the `config` command to support the `--global` / `-g` flag for storing settings at the global level
15+
- Added enhanced display of config sources in the `list` and `get` commands
16+
- Added tests for the hierarchical configuration system
17+
18+
## Usage Examples
19+
20+
```bash
21+
# Set a project-level config (default)
22+
mycoder config set githubMode true
23+
24+
# Set a global config
25+
mycoder config set model claude-3-opus --global
26+
# or using the short flag
27+
mycoder config set provider anthropic -g
28+
29+
# List all settings (showing merged config with source indicators)
30+
mycoder config list
31+
32+
# List only global settings
33+
mycoder config list --global
34+
35+
# Get a specific setting (showing source)
36+
mycoder config get model
37+
38+
# Clear a setting at project level
39+
mycoder config clear githubMode
40+
41+
# Clear a setting at global level
42+
mycoder config clear model --global
43+
44+
# Clear all project settings
45+
mycoder config clear --all
46+
47+
# Clear all global settings
48+
mycoder config clear --all --global
49+
```
50+
51+
## Implementation Details
52+
53+
The configuration system now uses the [deepmerge](https://github.com/TehShrike/deepmerge) package to properly merge configuration objects from different levels. When retrieving configuration, it starts with the defaults and progressively applies global, project, and CLI options.
54+
55+
When displaying configuration, the system now shows the source of each setting (project, global, or default) to make it clear where each value is coming from.
56+
57+
## Testing
58+
59+
Added unit tests to verify the hierarchical configuration system works as expected.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"dependencies": {
5050
"@sentry/node": "^9.3.0",
5151
"chalk": "^5",
52+
"deepmerge": "^4.3.1",
5253
"dotenv": "^16",
5354
"mycoder-agent": "workspace:*",
5455
"semver": "^7.7.1",

packages/cli/src/commands/config.ts

Lines changed: 108 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,39 @@ export const command: CommandModule<SharedOptions, ConfigOptions> = {
122122
argv.global || argv.g ? ConfigLevel.GLOBAL : ConfigLevel.PROJECT;
123123
const levelName = configLevel === ConfigLevel.GLOBAL ? 'global' : 'project';
124124

125+
// Check if project level is writable when needed for operations that write to config
126+
if (
127+
configLevel === ConfigLevel.PROJECT &&
128+
(argv.command === 'set' ||
129+
(argv.command === 'clear' && (argv.key || argv.all)))
130+
) {
131+
try {
132+
// Import directly to avoid circular dependency
133+
const { isProjectSettingsDirWritable } = await import(
134+
'../settings/settings.js'
135+
);
136+
if (!isProjectSettingsDirWritable()) {
137+
logger.error(
138+
chalk.red(
139+
'Cannot write to project configuration directory. Check permissions or use --global flag.',
140+
),
141+
);
142+
logger.info(
143+
'You can use the --global (-g) flag to modify global configuration instead.',
144+
);
145+
return;
146+
}
147+
} catch (error: unknown) {
148+
const errorMessage = error instanceof Error ? error.message : String(error);
149+
logger.error(
150+
chalk.red(
151+
`Error checking project directory permissions: ${errorMessage}`,
152+
),
153+
);
154+
return;
155+
}
156+
}
157+
125158
// Get merged config for display
126159
const mergedConfig = getConfig();
127160

@@ -287,15 +320,27 @@ export const command: CommandModule<SharedOptions, ConfigOptions> = {
287320
}
288321
}
289322

290-
// Update config at the specified level
291-
const updatedConfig = updateConfig(
292-
{ [argv.key]: parsedValue },
293-
configLevel,
294-
);
323+
try {
324+
// Update config at the specified level
325+
const updatedConfig = updateConfig(
326+
{ [argv.key]: parsedValue },
327+
configLevel,
328+
);
295329

296-
logger.info(
297-
`Updated ${argv.key}: ${chalk.green(updatedConfig[argv.key as keyof typeof updatedConfig])} at ${levelName} level`,
298-
);
330+
logger.info(
331+
`Updated ${argv.key}: ${chalk.green(updatedConfig[argv.key as keyof typeof updatedConfig])} at ${levelName} level`,
332+
);
333+
} catch (error: unknown) {
334+
const errorMessage = error instanceof Error ? error.message : String(error);
335+
logger.error(
336+
chalk.red(`Failed to update configuration: ${errorMessage}`),
337+
);
338+
if (configLevel === ConfigLevel.PROJECT) {
339+
logger.info(
340+
'You can use the --global (-g) flag to modify global configuration instead.',
341+
);
342+
}
343+
}
299344
return;
300345
}
301346

@@ -340,11 +385,23 @@ export const command: CommandModule<SharedOptions, ConfigOptions> = {
340385
return;
341386
}
342387

343-
// Clear settings at the specified level
344-
clearConfigAtLevel(configLevel);
345-
logger.info(
346-
`All ${levelName} configuration settings have been cleared.`,
347-
);
388+
try {
389+
// Clear settings at the specified level
390+
clearConfigAtLevel(configLevel);
391+
logger.info(
392+
`All ${levelName} configuration settings have been cleared.`,
393+
);
394+
} catch (error: unknown) {
395+
const errorMessage = error instanceof Error ? error.message : String(error);
396+
logger.error(
397+
chalk.red(`Failed to clear configuration: ${errorMessage}`),
398+
);
399+
if (configLevel === ConfigLevel.PROJECT) {
400+
logger.info(
401+
'You can use the --global (-g) flag to modify global configuration instead.',
402+
);
403+
}
404+
}
348405
return;
349406
}
350407

@@ -374,33 +431,45 @@ export const command: CommandModule<SharedOptions, ConfigOptions> = {
374431
return;
375432
}
376433

377-
// Clear the key at the specified level
378-
clearConfigKey(argv.key, configLevel);
379-
380-
// Get the value that will now be used
381-
const mergedAfterClear = getConfig();
382-
const newValue =
383-
mergedAfterClear[argv.key as keyof typeof mergedAfterClear];
384-
385-
// Determine the source of the new value
386-
const afterClearInProject =
387-
argv.key in getConfigAtLevel(ConfigLevel.PROJECT);
388-
const afterClearInGlobal =
389-
argv.key in getConfigAtLevel(ConfigLevel.GLOBAL);
390-
const isDefaultAfterClear = !afterClearInProject && !afterClearInGlobal;
391-
392-
let sourceDisplay = '';
393-
if (isDefaultAfterClear) {
394-
sourceDisplay = '(default)';
395-
} else if (afterClearInProject) {
396-
sourceDisplay = '(from project config)';
397-
} else if (afterClearInGlobal) {
398-
sourceDisplay = '(from global config)';
399-
}
434+
try {
435+
// Clear the key at the specified level
436+
clearConfigKey(argv.key, configLevel);
400437

401-
logger.info(
402-
`Cleared ${argv.key} at ${levelName} level, now using: ${chalk.green(newValue)} ${sourceDisplay}`,
403-
);
438+
// Get the value that will now be used
439+
const mergedAfterClear = getConfig();
440+
const newValue =
441+
mergedAfterClear[argv.key as keyof typeof mergedAfterClear];
442+
443+
// Determine the source of the new value
444+
const afterClearInProject =
445+
argv.key in getConfigAtLevel(ConfigLevel.PROJECT);
446+
const afterClearInGlobal =
447+
argv.key in getConfigAtLevel(ConfigLevel.GLOBAL);
448+
const isDefaultAfterClear = !afterClearInProject && !afterClearInGlobal;
449+
450+
let sourceDisplay = '';
451+
if (isDefaultAfterClear) {
452+
sourceDisplay = '(default)';
453+
} else if (afterClearInProject) {
454+
sourceDisplay = '(from project config)';
455+
} else if (afterClearInGlobal) {
456+
sourceDisplay = '(from global config)';
457+
}
458+
459+
logger.info(
460+
`Cleared ${argv.key} at ${levelName} level, now using: ${chalk.green(newValue)} ${sourceDisplay}`,
461+
);
462+
} catch (error: unknown) {
463+
const errorMessage = error instanceof Error ? error.message : String(error);
464+
logger.error(
465+
chalk.red(`Failed to clear configuration key: ${errorMessage}`),
466+
);
467+
if (configLevel === ConfigLevel.PROJECT) {
468+
logger.info(
469+
'You can use the --global (-g) flag to modify global configuration instead.',
470+
);
471+
}
472+
}
404473
return;
405474
}
406475

packages/cli/src/settings/config.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,23 @@ import * as path from 'path';
33

44
import * as deepmerge from 'deepmerge';
55

6-
import { getGlobalSettingsDir, getProjectSettingsDir } from './settings.js';
6+
import {
7+
getSettingsDir,
8+
getProjectSettingsDir,
9+
isProjectSettingsDirWritable,
10+
} from './settings.js';
711

812
// File paths for different config levels
9-
const globalConfigFile = path.join(getGlobalSettingsDir(), 'config.json');
10-
const projectConfigFile = path.join(getProjectSettingsDir(), 'config.json');
13+
const globalConfigFile = path.join(getSettingsDir(), 'config.json');
14+
15+
// Export for testing
16+
export const getProjectConfigFile = (): string => {
17+
const projectDir = getProjectSettingsDir();
18+
return projectDir ? path.join(projectDir, 'config.json') : '';
19+
};
20+
21+
// For internal use
22+
const projectConfigFile = getProjectConfigFile;
1123

1224
// Default configuration
1325
const defaultConfig = {
@@ -52,7 +64,7 @@ export const getDefaultConfig = (): Config => {
5264
* @returns The config object or an empty object if the file doesn't exist or is invalid
5365
*/
5466
const readConfigFile = (filePath: string): Partial<Config> => {
55-
if (!fs.existsSync(filePath)) {
67+
if (!filePath || !fs.existsSync(filePath)) {
5668
return {};
5769
}
5870
try {
@@ -74,7 +86,7 @@ export const getConfigAtLevel = (level: ConfigLevel): Partial<Config> => {
7486
case ConfigLevel.GLOBAL:
7587
return readConfigFile(globalConfigFile);
7688
case ConfigLevel.PROJECT:
77-
return readConfigFile(projectConfigFile);
89+
return readConfigFile(projectConfigFile());
7890
case ConfigLevel.CLI:
7991
return {}; // CLI options are passed directly from the command
8092
default:
@@ -124,7 +136,18 @@ export const updateConfig = (
124136
targetFile = globalConfigFile;
125137
break;
126138
case ConfigLevel.PROJECT:
127-
targetFile = projectConfigFile;
139+
// Check if project config directory is writable
140+
if (!isProjectSettingsDirWritable()) {
141+
throw new Error(
142+
'Cannot write to project configuration directory. Check permissions or use --global flag.',
143+
);
144+
}
145+
targetFile = projectConfigFile();
146+
if (!targetFile) {
147+
throw new Error(
148+
'Cannot determine project configuration file path. Use --global flag instead.',
149+
);
150+
}
128151
break;
129152
default:
130153
throw new Error(`Cannot update configuration at level: ${level}`);
@@ -157,7 +180,17 @@ export const clearConfigAtLevel = (level: ConfigLevel): Config => {
157180
targetFile = globalConfigFile;
158181
break;
159182
case ConfigLevel.PROJECT:
160-
targetFile = projectConfigFile;
183+
// Check if project config directory is writable
184+
if (!isProjectSettingsDirWritable()) {
185+
throw new Error(
186+
'Cannot write to project configuration directory. Check permissions or use --global flag.',
187+
);
188+
}
189+
targetFile = projectConfigFile();
190+
if (!targetFile) {
191+
// If no project config file exists, nothing to clear
192+
return getConfig();
193+
}
161194
break;
162195
default:
163196
throw new Error(`Cannot clear configuration at level: ${level}`);
@@ -190,7 +223,17 @@ export const clearConfigKey = (
190223
targetFile = globalConfigFile;
191224
break;
192225
case ConfigLevel.PROJECT:
193-
targetFile = projectConfigFile;
226+
// Check if project config directory is writable
227+
if (!isProjectSettingsDirWritable()) {
228+
throw new Error(
229+
'Cannot write to project configuration directory. Check permissions or use --global flag.',
230+
);
231+
}
232+
targetFile = projectConfigFile();
233+
if (!targetFile) {
234+
// If no project config file exists, nothing to clear
235+
return getConfig();
236+
}
194237
break;
195238
default:
196239
throw new Error(`Cannot clear key at configuration level: ${level}`);

0 commit comments

Comments
 (0)