diff --git a/.husky/commit-msg b/.husky/commit-msg index 125042e..ea7a72b 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,2 +1,2 @@ # Validate commit message with commitlint -# pnpm exec commitlint --edit $1 \ No newline at end of file +pnpm exec commitlint --edit $1 \ No newline at end of file diff --git a/packages/cli/.mycoder/config.json b/packages/cli/.mycoder/config.json new file mode 100644 index 0000000..793ffbc --- /dev/null +++ b/packages/cli/.mycoder/config.json @@ -0,0 +1,4 @@ +{ + "provider": "anthropic", + "model": "claude-3-7-sonnet-20250219" +} \ No newline at end of file diff --git a/packages/cli/README.md b/packages/cli/README.md index d07369c..bae858e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -71,23 +71,52 @@ Requirements for GitHub mode: ## Configuration -MyCoder stores configuration in `~/.mycoder/config.json`. You can manage configuration using the `config` command: +MyCoder uses a configuration file in your project directory. To create a default configuration file, run: ```bash -# List all configuration -mycoder config list +# Create a default configuration file +mycoder init -# Get a specific configuration value -mycoder config get githubMode - -# Set a configuration value -mycoder config set githubMode true - -# Reset a configuration value to its default -mycoder config clear customPrompt +# Force overwrite an existing configuration file +mycoder init --force +``` +This will create a `mycoder.config.js` file in your current directory with default settings that you can customize. + +Example configuration file: + +```javascript +// mycoder.config.js +export default { + // GitHub integration + githubMode: true, + + // Browser settings + headless: true, + userSession: false, + pageFilter: 'none', // 'simple', 'none', or 'readability' + + // Model settings + provider: 'anthropic', + model: 'claude-3-7-sonnet-20250219', + maxTokens: 4096, + temperature: 0.7, + + // Custom settings + customPrompt: '', + profile: false, + tokenCache: true, + + // API keys (better to use environment variables for these) + // ANTHROPIC_API_KEY: 'your-api-key', +}; ``` +MyCoder will search for configuration in the following places (in order of precedence): +1. CLI options (e.g., `--githubMode true`) +2. Configuration file (`mycoder.config.js`, `.mycoderrc`, etc.) +3. Default values + ### Model Selection NOTE: Anthropic Claude 3.7 works the best by far in our testing. diff --git a/packages/cli/package.json b/packages/cli/package.json index f3e849c..6f7a6c9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,6 +47,7 @@ "dependencies": { "@sentry/node": "^9.3.0", "chalk": "^5", + "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.1", "dotenv": "^16", "mycoder-agent": "workspace:*", diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts deleted file mode 100644 index c5b29f6..0000000 --- a/packages/cli/src/commands/config.ts +++ /dev/null @@ -1,358 +0,0 @@ -import * as path from 'path'; - -import chalk from 'chalk'; -import { Logger } from 'mycoder-agent'; - -import { SharedOptions } from '../options.js'; -import { - getConfig, - getDefaultConfig, - updateConfig, - getConfigAtLevel, - clearConfigAtLevel, - clearConfigKey, - ConfigLevel, -} from '../settings/config.js'; -import { nameToLogIndex } from '../utils/nameToLogIndex.js'; - -import type { CommandModule, ArgumentsCamelCase } from 'yargs'; - -export interface ConfigOptions extends SharedOptions { - command: 'get' | 'set' | 'list' | 'clear'; - key?: string; - value?: string; - all: boolean; // Has default value in builder, so it's always defined - global: boolean; // Has default value in builder, so it's always defined - verbose: boolean; // Has default value in builder, so it's always defined -} - -export const command: CommandModule = { - command: 'config [key] [value]', - describe: 'Manage MyCoder configuration', - builder: (yargs) => { - return yargs - .positional('command', { - describe: 'Config command to run', - choices: ['get', 'set', 'list', 'clear'], - type: 'string', - demandOption: true, - }) - .positional('key', { - describe: 'Configuration key', - type: 'string', - }) - .positional('value', { - describe: 'Configuration value (for set command)', - type: 'string', - }) - .option('all', { - describe: 'Clear all configuration settings (for clear command)', - type: 'boolean', - default: false, - }) - .option('global', { - alias: 'g', - describe: 'Use global configuration instead of project-level', - type: 'boolean', - default: false, - }) - .option('verbose', { - alias: 'v', - describe: 'Show detailed information including config file paths', - type: 'boolean', - default: false, - }) - .example('$0 config list', 'List all configuration values') - .example( - '$0 config get githubMode', - 'Get the value of githubMode setting', - ) - .example('$0 config set githubMode true', 'Enable GitHub mode') - .example( - '$0 config clear customPrompt', - 'Reset customPrompt to default value', - ) - .example( - '$0 config set ANTHROPIC_API_KEY ', - 'Store your Anthropic API key in configuration', - ) - .example( - '$0 config clear --all', - 'Clear all configuration settings', - ) as any; // eslint-disable-line @typescript-eslint/no-explicit-any - }, - handler: async (argv: ArgumentsCamelCase) => { - const logger = new Logger({ - name: 'Config', - logLevel: nameToLogIndex(argv.logLevel), - }); - - // Determine which config level to use based on flags - const configLevel = - argv.global || argv.g ? ConfigLevel.GLOBAL : ConfigLevel.PROJECT; - const levelName = configLevel === ConfigLevel.GLOBAL ? 'global' : 'project'; - - // Check if project level is writable when needed for operations that write to config - if ( - configLevel === ConfigLevel.PROJECT && - (argv.command === 'set' || - (argv.command === 'clear' && (argv.key || argv.all))) - ) { - try { - // Import directly to avoid circular dependency - const { isProjectSettingsDirWritable } = await import( - '../settings/settings.js' - ); - if (!isProjectSettingsDirWritable()) { - logger.error( - chalk.red( - 'Cannot write to project configuration directory. Check permissions or use --global flag.', - ), - ); - logger.info( - 'You can use the --global (-g) flag to modify global configuration instead.', - ); - return; - } - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logger.error( - chalk.red( - `Error checking project directory permissions: ${errorMessage}`, - ), - ); - return; - } - } - - // Get merged config for display - const config = getConfig(); - - // Handle 'list' command - if (argv.command === 'list') { - // Import directly to avoid circular dependency - const { getSettingsDir } = await import('../settings/settings.js'); - const { getProjectConfigFile } = await import('../settings/config.js'); - - const globalConfigFile = path.join(getSettingsDir(), 'config.json'); - const projectConfigFile = getProjectConfigFile(); - - logger.info('Current configuration:'); - logger.info(`Global config file: ${globalConfigFile}`); - logger.info(`Project config file: ${projectConfigFile}`); - logger.info(''); - - const defaultConfig = getDefaultConfig(); - - // Get all valid config keys - const validKeys = Object.keys(defaultConfig); - - // Filter and sort config entries - const configEntries = Object.entries(config) - .filter(([key]) => validKeys.includes(key)) - .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); - - // Display config entries with default indicators - configEntries.forEach(([key, value]) => { - const isDefault = - JSON.stringify(value) === - JSON.stringify(defaultConfig[key as keyof typeof defaultConfig]); - const valueDisplay = isDefault - ? chalk.dim(`${value} (default)`) - : chalk.green(value); - logger.info(` ${key}: ${valueDisplay}`); - }); - return; - } - - // Handle 'get' command - if (argv.command === 'get') { - if (!argv.key) { - logger.error('Key is required for get command'); - return; - } - - if (argv.key in config) { - logger.info( - `${argv.key}: ${chalk.green(config[argv.key as keyof typeof config])}`, - ); - } else { - logger.error(`Configuration key '${argv.key}' not found`); - } - return; - } - - // Handle 'set' command - if (argv.command === 'set') { - if (!argv.key) { - logger.error('Key is required for set command'); - return; - } - - if (argv.value === undefined) { - logger.error('Value is required for set command'); - return; - } - - // Check if the key exists in default config - const defaultConfig = getDefaultConfig(); - if (!(argv.key in defaultConfig)) { - logger.warn( - `Warning: '${argv.key}' is not a standard configuration key`, - ); - logger.info( - `Valid configuration keys: ${Object.keys(defaultConfig).join(', ')}`, - ); - // Continue with the operation instead of returning - } - - // Parse the value based on current type or infer boolean/number - let parsedValue: string | boolean | number = argv.value; - - // Check if config already exists to determine type - if (argv.key in config) { - if (typeof config[argv.key as keyof typeof config] === 'boolean') { - parsedValue = argv.value.toLowerCase() === 'true'; - } else if ( - typeof config[argv.key as keyof typeof config] === 'number' - ) { - parsedValue = Number(argv.value); - } - } else { - // If config doesn't exist yet, try to infer type - if ( - argv.value.toLowerCase() === 'true' || - argv.value.toLowerCase() === 'false' - ) { - parsedValue = argv.value.toLowerCase() === 'true'; - } else if (!isNaN(Number(argv.value))) { - parsedValue = Number(argv.value); - } - } - - try { - // Update config at the specified level - const updatedConfig = updateConfig( - { [argv.key]: parsedValue }, - configLevel, - ); - - logger.info( - `Updated ${argv.key}: ${chalk.green(updatedConfig[argv.key as keyof typeof updatedConfig])} at ${levelName} level`, - ); - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logger.error( - chalk.red(`Failed to update configuration: ${errorMessage}`), - ); - if (configLevel === ConfigLevel.PROJECT) { - logger.info( - 'You can use the --global (-g) flag to modify global configuration instead.', - ); - } - } - return; - } - - // Handle 'clear' command - if (argv.command === 'clear') { - // Check if --all flag is provided - if (argv.all) { - try { - // Clear settings at the specified level - clearConfigAtLevel(configLevel); - logger.info( - `All ${levelName} configuration settings have been cleared.`, - ); - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logger.error( - chalk.red(`Failed to clear configuration: ${errorMessage}`), - ); - if (configLevel === ConfigLevel.PROJECT) { - logger.info( - 'You can use the --global (-g) flag to modify global configuration instead.', - ); - } - } - return; - } - - if (!argv.key) { - logger.error( - 'Key is required for clear command (or use --all to clear all settings)', - ); - return; - } - - const defaultConfig = getDefaultConfig(); - - // Check if the key exists in the config - if (!(argv.key in config)) { - logger.error(`Configuration key '${argv.key}' not found`); - return; - } - - // Check if the key exists in the default config - if (!(argv.key in defaultConfig)) { - logger.error( - `Configuration key '${argv.key}' does not have a default value`, - ); - return; - } - - // Clear the specified key from the configuration at the current level - clearConfigKey(argv.key, configLevel); - - // Get the default value that will now be used - const defaultValue = - defaultConfig[argv.key as keyof typeof defaultConfig]; - - // Get the effective config after clearing - const updatedConfig = getConfig(); - const newValue = updatedConfig[argv.key as keyof typeof updatedConfig]; - - // Determine where the new value is coming from - const isDefaultAfterClear = - JSON.stringify(newValue) === JSON.stringify(defaultValue); - - // Get the actual config values at each level - const globalConfig = getConfigAtLevel(ConfigLevel.GLOBAL); - const projectConfig = getConfigAtLevel(ConfigLevel.PROJECT); - - // Check if key exists AND has a non-default value in each level - const afterClearInGlobal = - !isDefaultAfterClear && - argv.key in globalConfig && - JSON.stringify(globalConfig[argv.key]) !== JSON.stringify(defaultValue); - - const afterClearInProject = - !isDefaultAfterClear && - !afterClearInGlobal && - argv.key in projectConfig && - JSON.stringify(projectConfig[argv.key]) !== - JSON.stringify(defaultValue); - - let sourceDisplay = ''; - if (isDefaultAfterClear) { - sourceDisplay = '(default)'; - } else if (afterClearInProject) { - sourceDisplay = '(from project config)'; - } else if (afterClearInGlobal) { - sourceDisplay = '(from global config)'; - } - - logger.info( - `Cleared ${argv.key} at ${levelName} level, now using: ${chalk.green(newValue)} ${sourceDisplay}`, - ); - return; - } - - // If command not recognized - logger.error(`Unknown config command: ${argv.command}`); - logger.info('Available commands: get, set, list, clear'); - }, -}; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..ce6e579 --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,67 @@ +import * as path from 'path'; + +import chalk from 'chalk'; +import { Logger } from 'mycoder-agent'; + +import { SharedOptions } from '../options.js'; +import { createDefaultConfigFile } from '../settings/config-loader.js'; +import { nameToLogIndex } from '../utils/nameToLogIndex.js'; + +import type { CommandModule, ArgumentsCamelCase } from 'yargs'; + +export interface InitOptions extends SharedOptions { + force: boolean; +} + +export const command: CommandModule = { + command: 'init', + describe: 'Initialize a new MyCoder configuration file', + builder: (yargs) => { + return yargs + .option('force', { + alias: 'f', + describe: 'Overwrite existing configuration file if it exists', + type: 'boolean', + default: false, + }) + .example('$0 init', 'Create a default mycoder.config.js file') + .example('$0 init --force', 'Overwrite existing configuration file'); + }, + handler: async (argv: ArgumentsCamelCase) => { + const logger = new Logger({ + name: 'Init', + logLevel: nameToLogIndex(argv.logLevel), + }); + + const configPath = path.join(process.cwd(), 'mycoder.config.js'); + + try { + // If force flag is set, delete existing file + if (argv.force) { + const fs = await import('fs'); + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } + } + + // Create default configuration file + const created = createDefaultConfigFile(configPath); + + if (created) { + logger.info(chalk.green(`Created configuration file: ${configPath}`)); + logger.info('Edit this file to customize MyCoder settings.'); + } else { + logger.error( + chalk.red(`Configuration file already exists: ${configPath}`), + ); + logger.info('Use --force to overwrite the existing file.'); + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.error( + chalk.red(`Failed to create configuration file: ${errorMessage}`), + ); + } + }, +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9e60706..ffb429a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,7 +6,7 @@ import yargs, { CommandModule } from 'yargs'; import { hideBin } from 'yargs/helpers'; import { command as defaultCommand } from './commands/$default.js'; -import { command as configCommand } from './commands/config.js'; +import { command as initCommand } from './commands/init.js'; import { command as testProfileCommand } from './commands/test-profile.js'; import { command as testSentryCommand } from './commands/test-sentry.js'; import { command as toolsCommand } from './commands/tools.js'; @@ -59,7 +59,7 @@ const main = async () => { testSentryCommand, testProfileCommand, toolsCommand, - configCommand, + initCommand, ] as CommandModule[]) .strict() .showHelpOnFail(true) diff --git a/packages/cli/src/settings/config-loader.ts b/packages/cli/src/settings/config-loader.ts new file mode 100644 index 0000000..b057593 --- /dev/null +++ b/packages/cli/src/settings/config-loader.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { cosmiconfigSync } from 'cosmiconfig'; + +import { Config } from './config.js'; + +// Default configuration +const defaultConfig: Config = { + // GitHub integration + githubMode: true, + + // Browser settings + headless: true, + userSession: false, + pageFilter: 'none' as 'simple' | 'none' | 'readability', + + // Model settings + provider: 'anthropic', + model: 'claude-3-7-sonnet-20250219', + maxTokens: 4096, + temperature: 0.7, + + // Custom settings + customPrompt: '', + profile: false, + tokenCache: true, + + // API keys (empty by default) + ANTHROPIC_API_KEY: '', +}; + +/** + * Load configuration using cosmiconfig + * @returns Merged configuration with default values + */ +export function loadConfig(cliOptions: Partial = {}): Config { + // Initialize cosmiconfig + const explorer = cosmiconfigSync('mycoder', { + searchPlaces: [ + 'mycoder.config.js', + 'mycoder.config.cjs', + 'mycoder.config.mjs', + '.mycoderrc', + '.mycoderrc.json', + '.mycoderrc.yaml', + '.mycoderrc.yml', + '.mycoderrc.js', + '.mycoderrc.cjs', + '.mycoderrc.mjs', + 'package.json', + ], + }); + + // Search for configuration file + const result = explorer.search(); + + // Merge configurations with precedence: default < file < cli + const fileConfig = result?.config || {}; + + // Return merged configuration + return { + ...defaultConfig, + ...fileConfig, + ...cliOptions, + }; +} + +/** + * Create a default configuration file if it doesn't exist + * @param filePath Path to create the configuration file + * @returns true if file was created, false if it already exists + */ +export function createDefaultConfigFile(filePath?: string): boolean { + // Default to current directory if no path provided + const configPath = filePath || path.join(process.cwd(), 'mycoder.config.js'); + + // Check if file already exists + if (fs.existsSync(configPath)) { + return false; + } + + // Create default configuration file + const configContent = `// mycoder.config.js +export default { + // GitHub integration + githubMode: true, + + // Browser settings + headless: true, + userSession: false, + pageFilter: 'none', // 'simple', 'none', or 'readability' + + // Model settings + provider: 'anthropic', + model: 'claude-3-7-sonnet-20250219', + maxTokens: 4096, + temperature: 0.7, + + // Custom settings + customPrompt: '', + profile: false, + tokenCache: true, + + // API keys (better to use environment variables for these) + // ANTHROPIC_API_KEY: 'your-api-key', +}; +`; + + fs.writeFileSync(configPath, configContent); + return true; +} diff --git a/packages/cli/src/settings/config.test.ts b/packages/cli/src/settings/config.test.ts index 789c08a..a678e56 100644 --- a/packages/cli/src/settings/config.test.ts +++ b/packages/cli/src/settings/config.test.ts @@ -1,83 +1,58 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import { describe, it, expect, vi } from 'vitest'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { getConfig, getDefaultConfig, Config } from './config'; +import * as configLoader from './config-loader'; -// Mock modules -vi.mock('fs', () => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), - unlinkSync: vi.fn(), +// Mock the config-loader module +vi.mock('./config-loader', () => ({ + loadConfig: vi.fn(), })); -vi.mock('path', () => ({ - join: vi.fn(), -})); - -// Mock settings module -vi.mock('./settings.js', () => ({ - getSettingsDir: vi.fn().mockReturnValue('/test/home/dir/.mycoder'), - getProjectSettingsDir: vi.fn().mockReturnValue('/test/project/dir/.mycoder'), - isProjectSettingsDirWritable: vi.fn().mockReturnValue(true), -})); - -// Import after mocking -import { readConfigFile } from './config.js'; - -describe('Hierarchical Configuration', () => { - // Mock file paths - const mockGlobalConfigPath = '/test/home/dir/.mycoder/config.json'; - const mockProjectConfigPath = '/test/project/dir/.mycoder/config.json'; - - // Mock config data - const mockGlobalConfig = { - provider: 'openai', - model: 'gpt-4', - }; - - const mockProjectConfig = { - model: 'claude-3-opus', - }; - - beforeEach(() => { - vi.resetAllMocks(); - - // Set environment - process.env.VITEST = 'true'; - - // Mock path.join - vi.mocked(path.join).mockImplementation((...args) => { - if (args.includes('/test/home/dir/.mycoder')) { - return mockGlobalConfigPath; - } - if (args.includes('/test/project/dir/.mycoder')) { - return mockProjectConfigPath; - } - return args.join('/'); - }); - - // Mock fs.existsSync - vi.mocked(fs.existsSync).mockReturnValue(true); - - // Mock fs.readFileSync - vi.mocked(fs.readFileSync).mockImplementation((filePath) => { - if (filePath === mockGlobalConfigPath) { - return JSON.stringify(mockGlobalConfig); - } - if (filePath === mockProjectConfigPath) { - return JSON.stringify(mockProjectConfig); - } - return ''; - }); +describe('config', () => { + it('getConfig should call loadConfig with CLI options', () => { + const mockConfig: Config = { + githubMode: true, + headless: false, + userSession: false, + pageFilter: 'none', + provider: 'anthropic', + model: 'claude-3-7-sonnet-20250219', + maxTokens: 4096, + temperature: 0.7, + customPrompt: '', + profile: false, + tokenCache: true, + ANTHROPIC_API_KEY: '', + }; + vi.mocked(configLoader.loadConfig).mockReturnValue(mockConfig); + + const cliOptions = { headless: false }; + const result = getConfig(cliOptions); + + expect(configLoader.loadConfig).toHaveBeenCalledWith(cliOptions); + expect(result).toEqual(mockConfig); }); - // Only test the core function that's actually testable - it('should read config files correctly', () => { - const globalConfig = readConfigFile(mockGlobalConfigPath); - expect(globalConfig).toEqual(mockGlobalConfig); - - const projectConfig = readConfigFile(mockProjectConfigPath); - expect(projectConfig).toEqual(mockProjectConfig); + it('getDefaultConfig should call loadConfig with no arguments', () => { + const mockConfig: Config = { + githubMode: true, + headless: true, + userSession: false, + pageFilter: 'none', + provider: 'anthropic', + model: 'claude-3-7-sonnet-20250219', + maxTokens: 4096, + temperature: 0.7, + customPrompt: '', + profile: false, + tokenCache: true, + ANTHROPIC_API_KEY: '', + }; + vi.mocked(configLoader.loadConfig).mockReturnValue(mockConfig); + + const result = getDefaultConfig(); + + expect(configLoader.loadConfig).toHaveBeenCalledWith(); + expect(result).toEqual(mockConfig); }); }); diff --git a/packages/cli/src/settings/config.ts b/packages/cli/src/settings/config.ts index 677fee0..ca6ba4e 100644 --- a/packages/cli/src/settings/config.ts +++ b/packages/cli/src/settings/config.ts @@ -1,319 +1,46 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import { loadConfig } from './config-loader.js'; -import deepmerge from 'deepmerge'; +// Default configuration type definition +export type Config = { + // GitHub integration + githubMode: boolean; -import { - getSettingsDir, - getProjectSettingsDir, - isProjectSettingsDirWritable, -} from './settings.js'; + // Browser settings + headless: boolean; + userSession: boolean; + pageFilter: 'simple' | 'none' | 'readability'; -// Configuration levels enum -export enum ConfigLevel { - DEFAULT = 'default', - GLOBAL = 'global', - PROJECT = 'project', - CLI = 'cli', -} + // Model settings + provider: string; + model: string; + maxTokens: number; + temperature: number; -// File paths for different config levels -const globalConfigFile = path.join(getSettingsDir(), 'config.json'); + // Custom settings + customPrompt: string; + profile: boolean; + tokenCache: boolean; -// Export for testing -export const getProjectConfigFile = (): string => { - const projectDir = getProjectSettingsDir(); + // API keys + ANTHROPIC_API_KEY: string; - // Ensure the project directory exists - if (projectDir && !fs.existsSync(projectDir)) { - try { - fs.mkdirSync(projectDir, { recursive: true }); - } catch (error) { - console.error(`Error creating project settings directory: ${error}`); - return ''; - } - } - - return projectDir ? path.join(projectDir, 'config.json') : ''; -}; - -// For internal use - use the function directly to ensure it's properly mocked in tests -const projectConfigFile = (): string => getProjectConfigFile(); - -// Default configuration -const defaultConfig = { - // Add default configuration values here - githubMode: true, - headless: true, - userSession: false, - pageFilter: 'none' as 'simple' | 'none' | 'readability', - provider: 'anthropic', - model: 'claude-3-7-sonnet-20250219', - maxTokens: 4096, - temperature: 0.7, - customPrompt: '', - profile: false, - tokenCache: true, - // API keys (empty by default) - ANTHROPIC_API_KEY: '', -}; - -export type Config = typeof defaultConfig; - -// Export the default config for use in other functions -export const getDefaultConfig = (): Config => { - return { ...defaultConfig }; -}; - -/** - * Read a config file from disk - * @param filePath Path to the config file - * @returns The config object or an empty object if the file doesn't exist or is invalid - */ -export const readConfigFile = (filePath: string): Partial => { - if (!filePath || !fs.existsSync(filePath)) { - return {}; - } - try { - const fileContent = fs.readFileSync(filePath, 'utf-8'); - return JSON.parse(fileContent); - } catch { - return defaultConfig; - } -}; - -/** - * Get configuration from a specific level - * @param level The configuration level to retrieve - * @returns The configuration at the specified level - */ -export const getConfigAtLevel = (level: ConfigLevel): Partial => { - let configFile: string; - - switch (level) { - case ConfigLevel.DEFAULT: - return getDefaultConfig(); - case ConfigLevel.GLOBAL: - configFile = globalConfigFile; - return readConfigFile(configFile); - case ConfigLevel.PROJECT: - configFile = projectConfigFile(); - return configFile ? readConfigFile(configFile) : {}; - case ConfigLevel.CLI: - return {}; // CLI options are passed directly from the command - default: - return {}; - } + // Additional properties can be added by users + [key: string]: any; }; /** - * Get the merged configuration from all levels + * Get the configuration by loading from config files and merging with CLI options * @param cliOptions Optional CLI options to include in the merge - * @returns The merged configuration with all levels applied + * @returns The merged configuration */ export const getConfig = (cliOptions: Partial = {}): Config => { - // Start with default config - const defaultConf = getDefaultConfig(); - - // Read global config - const globalConf = getConfigAtLevel(ConfigLevel.GLOBAL); - - // Read project config - const projectConf = getConfigAtLevel(ConfigLevel.PROJECT); - - // For tests, use a simpler merge approach when testing - if (process.env.VITEST) { - return { - ...defaultConf, - ...globalConf, - ...projectConf, - ...cliOptions, - } as Config; - } - - // Merge in order of precedence: default < global < project < cli - return deepmerge.all([ - defaultConf, - globalConf, - projectConf, - cliOptions, - ]) as Config; + return loadConfig(cliOptions); }; /** - * Update configuration at a specific level - * @param config Configuration changes to apply - * @param level The level at which to apply the changes - * @returns The new merged configuration after the update + * Get the default configuration + * @returns A copy of the default configuration */ -export const updateConfig = ( - config: Partial, - level: ConfigLevel = ConfigLevel.PROJECT, -): Config => { - let targetFile: string; - - // Determine which file to update - switch (level) { - case ConfigLevel.GLOBAL: - targetFile = globalConfigFile; - break; - case ConfigLevel.PROJECT: - // Check if project config directory is writable - if (!isProjectSettingsDirWritable()) { - throw new Error( - 'Cannot write to project configuration directory. Check permissions or use --global flag.', - ); - } - targetFile = projectConfigFile(); - if (!targetFile) { - throw new Error( - 'Cannot determine project configuration file path. Use --global flag instead.', - ); - } - break; - default: - throw new Error(`Cannot update configuration at level: ${level}`); - } - - // Read current config at the target level - const currentLevelConfig = readConfigFile(targetFile); - - // Merge the update with the current config at this level - const updatedLevelConfig = { ...currentLevelConfig, ...config }; - - // Write the updated config back to the file - try { - fs.writeFileSync(targetFile, JSON.stringify(updatedLevelConfig, null, 2)); - } catch (error) { - console.error(`Error writing to ${targetFile}:`, error); - throw error; - } - - // For tests, return just the updated level config when in test environment - if (process.env.NODE_ENV === 'test' || process.env.VITEST) { - // For tests, return just the config that was passed in - return config as Config; - } - - // Return the new merged configuration - return getConfig(); -}; - -/** - * Clears configuration settings at a specific level - * @param level The level at which to clear settings - * @returns The new merged configuration after clearing - */ -export const clearConfigAtLevel = (level: ConfigLevel): Config => { - let targetFile: string; - - // Determine which file to clear - switch (level) { - case ConfigLevel.GLOBAL: - targetFile = globalConfigFile; - break; - case ConfigLevel.PROJECT: - // Check if project config directory is writable - if (!isProjectSettingsDirWritable()) { - throw new Error( - 'Cannot write to project configuration directory. Check permissions or use --global flag.', - ); - } - targetFile = projectConfigFile(); - if (!targetFile) { - // If no project config file exists, nothing to clear - return getConfig(); - } - break; - default: - throw new Error(`Cannot clear configuration at level: ${level}`); - } - - // Remove the config file if it exists - if (fs.existsSync(targetFile)) { - fs.unlinkSync(targetFile); - } - - // For tests, return empty config - if (process.env.VITEST) { - return getDefaultConfig(); - } - - // Return the new merged configuration - return getConfig(); -}; - -/** - * Clears a specific key from configuration at a specific level - * @param key The key to clear - * @param level The level from which to clear the key - * @returns The new merged configuration after clearing - */ -export const clearConfigKey = ( - key: string, - level: ConfigLevel = ConfigLevel.PROJECT, -): Config => { - let targetFile: string; - - // Determine which file to update - switch (level) { - case ConfigLevel.GLOBAL: - targetFile = globalConfigFile; - break; - case ConfigLevel.PROJECT: - // Check if project config directory is writable - if (!isProjectSettingsDirWritable()) { - throw new Error( - 'Cannot write to project configuration directory. Check permissions or use --global flag.', - ); - } - targetFile = projectConfigFile(); - if (!targetFile) { - // If no project config file exists, nothing to clear - return getConfig(); - } - break; - default: - throw new Error(`Cannot clear key at configuration level: ${level}`); - } - - // Read current config at the target level - const currentLevelConfig = readConfigFile(targetFile); - - // Skip if the key doesn't exist - if (!(key in currentLevelConfig)) { - return getConfig(); - } - - // Create a new config without the specified key - const { [key]: removedValue, ...newConfig } = currentLevelConfig as Record< - string, - any - >; - console.log(`Removed value for key ${key}:`, removedValue); - - // Write the updated config back to the file - console.log(`Clearing key ${key} from ${targetFile}`); - console.log(`Original config:`, JSON.stringify(currentLevelConfig, null, 2)); - console.log(`New config without key:`, JSON.stringify(newConfig, null, 2)); - - fs.writeFileSync(targetFile, JSON.stringify(newConfig, null, 2)); - - // Return the new merged configuration - return getConfig(); -}; - -/** - * For backwards compatibility - clears all configuration - * @returns The default configuration that will now be used - */ -export const clearAllConfig = (): Config => { - // Clear both global and project configs for backwards compatibility - clearConfigAtLevel(ConfigLevel.GLOBAL); - try { - clearConfigAtLevel(ConfigLevel.PROJECT); - } catch { - // Ignore errors when clearing project config - } - return getDefaultConfig(); +export const getDefaultConfig = (): Config => { + return loadConfig(); }; diff --git a/packages/cli/tests/commands/config.test.ts b/packages/cli/tests/commands/config.test.ts deleted file mode 100644 index 6bf692f..0000000 --- a/packages/cli/tests/commands/config.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { Logger } from 'mycoder-agent'; -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; - -import { command } from '../../src/commands/config.js'; -import { - getConfig, - getDefaultConfig, - updateConfig, - getConfigAtLevel, - clearConfigAtLevel, - clearConfigKey, -} from '../../src/settings/config.js'; - -// Mock dependencies -vi.mock('../../src/settings/config.js', () => ({ - getConfig: vi.fn(), - getDefaultConfig: vi.fn(), - updateConfig: vi.fn(), - getConfigAtLevel: vi.fn(), - clearConfigAtLevel: vi.fn(), - clearConfigKey: vi.fn(), - getProjectConfigFile: vi.fn().mockReturnValue('/mock/project/config.json'), - ConfigLevel: { - DEFAULT: 'default', - GLOBAL: 'global', - PROJECT: 'project', - CLI: 'cli', - }, -})); - -vi.mock('mycoder-agent', () => ({ - Logger: vi.fn().mockImplementation(() => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - })), - LogLevel: { - debug: 0, - verbose: 1, - info: 2, - warn: 3, - error: 4, - }, -})); - -vi.mock('../../src/utils/nameToLogIndex.js', () => ({ - nameToLogIndex: vi.fn().mockReturnValue(2), // info level -})); - -// Mock readline/promises -vi.mock('readline/promises', () => ({ - createInterface: vi.fn().mockImplementation(() => ({ - question: vi.fn().mockResolvedValue('y'), - close: vi.fn(), - })), -})); - -describe('Config Command', () => { - let mockLogger: { - info: ReturnType; - error: ReturnType; - warn: ReturnType; - }; - - beforeEach(() => { - mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - }; - vi.mocked(Logger).mockImplementation(() => mockLogger as unknown as Logger); - vi.mocked(getConfig).mockReturnValue({ githubMode: true }); - vi.mocked(getDefaultConfig).mockReturnValue({ - githubMode: true, - customPrompt: '', - }); - vi.mocked(updateConfig).mockImplementation((config) => ({ - githubMode: true, - ...config, - })); - vi.mocked(getConfigAtLevel).mockReturnValue({}); - vi.mocked(clearConfigKey).mockImplementation(() => ({ githubMode: true })); - vi.mocked(clearConfigKey).mockImplementation(() => ({ githubMode: true })); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should list all configuration values', async () => { - await command.handler!({ - _: ['config', 'list'], - logLevel: 'info', - interactive: false, - command: 'list', - global: false, - g: false, - } as any); - - expect(getConfig).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith('Current configuration:'); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('githubMode'), - ); - }); - - it('should filter out invalid config keys in list command', async () => { - // Mock getConfig to return config with invalid keys - vi.mocked(getConfig).mockReturnValue({ - githubMode: true, - invalidKey: 'some value', - } as any); - - // Mock getDefaultConfig to return only valid keys - vi.mocked(getDefaultConfig).mockReturnValue({ - githubMode: true, - }); - - await command.handler!({ - _: ['config', 'list'], - logLevel: 'info', - interactive: false, - command: 'list', - global: false, - g: false, - } as any); - - expect(getConfig).toHaveBeenCalled(); - expect(getDefaultConfig).toHaveBeenCalled(); - - // Should show the valid key - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('githubMode'), - ); - - // Should not show the invalid key - const infoCallArgs = mockLogger.info.mock.calls.flat(); - expect(infoCallArgs.join()).not.toContain('invalidKey'); - }); - - it('should get a configuration value', async () => { - await command.handler!({ - _: ['config', 'get', 'githubMode'], - logLevel: 'info', - interactive: false, - command: 'get', - key: 'githubMode', - global: false, - g: false, - } as any); - - expect(getConfig).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('githubMode'), - ); - }); - - it('should show error when getting non-existent key', async () => { - await command.handler!({ - _: ['config', 'get', 'nonExistentKey'], - logLevel: 'info', - interactive: false, - command: 'get', - key: 'nonExistentKey', - global: false, - g: false, - } as any); - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('not found'), - ); - }); - - it('should set a configuration value', async () => { - await command.handler!({ - _: ['config', 'set', 'githubMode', 'true'], - logLevel: 'info', - interactive: false, - command: 'set', - key: 'githubMode', - value: 'true', - global: false, - g: false, - } as any); - - expect(updateConfig).toHaveBeenCalledWith({ githubMode: true }, 'project'); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Updated'), - ); - }); - - it('should handle missing key for set command', async () => { - await command.handler!({ - _: ['config', 'set'], - logLevel: 'info', - interactive: false, - command: 'set', - key: undefined, - global: false, - g: false, - } as any); - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Key is required'), - ); - }); - - it('should handle missing value for set command', async () => { - await command.handler!({ - _: ['config', 'set', 'githubMode'], - logLevel: 'info', - interactive: false, - command: 'set', - key: 'githubMode', - value: undefined, - global: false, - g: false, - } as any); - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Value is required'), - ); - }); - - it('should warn when setting non-standard key', async () => { - // Mock getDefaultConfig to return config without the key - vi.mocked(getDefaultConfig).mockReturnValue({ - customPrompt: '', - }); - - await command.handler!({ - _: ['config', 'set', 'nonStandardKey', 'value'], - logLevel: 'info', - interactive: false, - command: 'set', - key: 'nonStandardKey', - value: 'value', - global: false, - g: false, - } as any); - - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('not a standard configuration key'), - ); - // Should still update the config - expect(updateConfig).toHaveBeenCalled(); - }); - - it('should clear a configuration value', async () => { - // Mock getConfig to include the key we want to clear - vi.mocked(getConfig).mockReturnValue({ - githubMode: true, - customPrompt: 'custom value', - }); - - // Mock getDefaultConfig to include the key we want to clear - vi.mocked(getDefaultConfig).mockReturnValue({ - githubMode: true, - customPrompt: '', - }); - - await command.handler!({ - _: ['config', 'clear', 'customPrompt'], - logLevel: 'info', - interactive: false, - command: 'clear', - key: 'customPrompt', - global: false, - g: false, - all: false, - } as any); - - // Verify success message - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Cleared customPrompt'), - ); - }); - - it('should handle missing key for clear command', async () => { - await command.handler!({ - _: ['config', 'clear'], - logLevel: 'info', - interactive: false, - command: 'clear', - key: undefined, - global: false, - g: false, - all: false, - } as any); - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Key is required'), - ); - }); - - it('should clear all project configuration with --all flag', async () => { - await command.handler!({ - _: ['config', 'clear'], - logLevel: 'info', - interactive: false, - command: 'clear', - all: true, - global: false, - g: false, - } as any); - - expect(clearConfigAtLevel).toHaveBeenCalledWith('project'); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining( - 'project configuration settings have been cleared', - ), - ); - }); - - it('should clear all global configuration with --all --global flags', async () => { - await command.handler!({ - _: ['config', 'clear'], - logLevel: 'info', - interactive: false, - command: 'clear', - all: true, - global: true, - g: false, - } as any); - - expect(clearConfigAtLevel).toHaveBeenCalledWith('global'); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining( - 'global configuration settings have been cleared', - ), - ); - }); - - it('should handle non-existent key for clear command', async () => { - vi.mocked(getConfig).mockReturnValue({ - githubMode: true, - }); - - await command.handler!({ - _: ['config', 'clear', 'nonExistentKey'], - logLevel: 'info', - interactive: false, - command: 'clear', - key: 'nonExistentKey', - global: false, - g: false, - all: false, - } as any); - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('not found'), - ); - }); - - it('should handle unknown command', async () => { - await command.handler!({ - _: ['config', 'unknown'], - logLevel: 'info', - interactive: false, - command: 'unknown' as any, - global: false, - g: false, - } as any); - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining('Unknown config command'), - ); - }); - - it('should list all configuration values with default indicators', async () => { - // Mock getConfig to return a mix of default and custom values - vi.mocked(getConfig).mockReturnValue({ - githubMode: true, // default value - customPrompt: 'custom value', // custom value - }); - - // Mock getDefaultConfig to return the default values - vi.mocked(getDefaultConfig).mockReturnValue({ - githubMode: true, - customPrompt: '', - }); - - await command.handler!({ - _: ['config', 'list'], - logLevel: 'info', - interactive: false, - command: 'list', - global: false, - g: false, - } as any); - - expect(getConfig).toHaveBeenCalled(); - expect(getDefaultConfig).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith('Current configuration:'); - - // Check for default indicator - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('githubMode') && - expect.stringContaining('(default)'), - ); - - // Check for custom value - const infoCallArgs = mockLogger.info.mock.calls.flat(); - const customPromptCall = infoCallArgs.find( - (arg) => typeof arg === 'string' && arg.includes('customPrompt'), - ); - expect(customPromptCall).toBeDefined(); - expect(customPromptCall).not.toContain('(default)'); - }); - - it('should use global config when --global flag is provided', async () => { - await command.handler!({ - _: ['config', 'set', 'githubMode', 'true'], - logLevel: 'info', - interactive: false, - command: 'set', - key: 'githubMode', - value: 'true', - global: true, - g: false, - } as any); - - expect(updateConfig).toHaveBeenCalledWith({ githubMode: true }, 'global'); - }); - - it('should use global config when -g flag is provided', async () => { - await command.handler!({ - _: ['config', 'set', 'githubMode', 'true'], - logLevel: 'info', - interactive: false, - command: 'set', - key: 'githubMode', - value: 'true', - global: false, - g: true, - } as any); - - expect(updateConfig).toHaveBeenCalledWith({ githubMode: true }, 'global'); - }); -}); diff --git a/packages/cli/tests/settings/config-loader.test.ts b/packages/cli/tests/settings/config-loader.test.ts new file mode 100644 index 0000000..32d299f --- /dev/null +++ b/packages/cli/tests/settings/config-loader.test.ts @@ -0,0 +1,78 @@ +import * as fs from 'fs'; + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + loadConfig, + createDefaultConfigFile, +} from '../../src/settings/config-loader'; + +// Mock cosmiconfig +vi.mock('cosmiconfig', () => { + return { + cosmiconfigSync: vi.fn(() => ({ + search: vi.fn(() => null), + })), + }; +}); + +// Mock fs +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: vi.fn(), + writeFileSync: vi.fn(), + }; +}); + +describe('config-loader', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('loadConfig', () => { + it('should return default config when no config file exists', () => { + const config = loadConfig(); + expect(config).toHaveProperty('githubMode'); + expect(config).toHaveProperty('headless'); + expect(config).toHaveProperty('model'); + }); + + it('should merge CLI options with default config', () => { + const cliOptions = { githubMode: false, headless: false }; + const config = loadConfig(cliOptions); + expect(config.githubMode).toBe(false); + expect(config.headless).toBe(false); + }); + }); + + describe('createDefaultConfigFile', () => { + it('should create a default config file when it does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + const result = createDefaultConfigFile('test-config.js'); + expect(result).toBe(true); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should not create a config file when it already exists', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const result = createDefaultConfigFile('test-config.js'); + expect(result).toBe(false); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should use the current directory if no path is provided', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + createDefaultConfigFile(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('mycoder.config.js'), + expect.any(String), + ); + }); + }); +}); diff --git a/packages/cli/tests/settings/config.test.ts b/packages/cli/tests/settings/config.test.ts deleted file mode 100644 index 2567a56..0000000 --- a/packages/cli/tests/settings/config.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; - -import { updateConfig } from '../../src/settings/config.js'; -import { getSettingsDir } from '../../src/settings/settings.js'; - -// Mock getProjectConfigFile -vi.mock( - '../../src/settings/config.js', - async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getProjectConfigFile: vi - .fn() - .mockReturnValue('/mock/project/dir/.mycoder/config.json'), - }; - }, - { partial: true }, -); - -// Mock the settings directory -vi.mock('../../src/settings/settings.js', () => { - return { - getSettingsDir: vi.fn().mockReturnValue('/mock/settings/dir'), - getProjectSettingsDir: vi - .fn() - .mockReturnValue('/mock/project/dir/.mycoder'), - isProjectSettingsDirWritable: vi.fn().mockReturnValue(true), - }; -}); - -// Mock fs module -vi.mock('fs', () => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), -})); - -describe('Config', () => { - const mockSettingsDir = '/mock/settings/dir'; - const mockConfigFile = path.join(mockSettingsDir, 'config.json'); - - beforeEach(() => { - vi.mocked(getSettingsDir).mockReturnValue(mockSettingsDir); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - beforeEach(() => { - // Reset all mocks before each test - vi.resetAllMocks(); - - // Set test environment - process.env.VITEST = 'true'; - }); - - describe('updateConfig', () => { - it('should update config and write to file', () => { - const currentConfig = { githubMode: true }; - const newConfig = { githubMode: true }; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(currentConfig)); - - // Force using GLOBAL level to avoid project directory issues - const result = updateConfig(newConfig, 'global'); - - expect(result).toEqual({ githubMode: true }); - expect(fs.writeFileSync).toHaveBeenCalledWith( - mockConfigFile, - JSON.stringify({ githubMode: true }, null, 2), - ); - }); - - it('should merge partial config with existing config', () => { - const currentConfig = { githubMode: true, existingSetting: 'value' }; - const partialConfig = { githubMode: true }; - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(currentConfig)); - - // In test mode, updateConfig returns just the config that was passed in - // This is a limitation of our test approach - updateConfig(partialConfig, 'global'); - - // Just verify the write was called with the right data - expect(fs.writeFileSync).toHaveBeenCalledWith( - mockConfigFile, - JSON.stringify({ githubMode: true, existingSetting: 'value' }, null, 2), - ); - }); - }); -}); diff --git a/packages/cli/tests/settings/project-config.test.ts b/packages/cli/tests/settings/project-config.test.ts deleted file mode 100644 index ea4a1c4..0000000 --- a/packages/cli/tests/settings/project-config.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; - -import { getProjectConfigFile } from '../../src/settings/config.js'; -import { getProjectSettingsDir } from '../../src/settings/settings.js'; - -// Mock fs module -vi.mock('fs', () => ({ - existsSync: vi.fn(), - mkdirSync: vi.fn(), - statSync: vi.fn(), -})); - -// Mock path module -vi.mock('path', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - parse: vi.fn(), - }; -}); - -// Only mock specific functions from settings.js -vi.mock('../../src/settings/settings.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getProjectSettingsDir: vi.fn(), - }; -}); - -describe('Project Config File', () => { - const mockCwd = '/mock/project/dir'; - const mockProjectDir = path.join(mockCwd, '.mycoder'); - const expectedConfigFile = path.join(mockProjectDir, 'config.json'); - - beforeEach(() => { - // Reset mocks - vi.resetAllMocks(); - - // Mock process.cwd() - vi.spyOn(process, 'cwd').mockReturnValue(mockCwd); - - // Mock path.parse - vi.mocked(path.parse).mockReturnValue({ - root: '/', - dir: '/mock', - base: 'dir', - name: 'dir', - ext: '', - }); - - // Default mock for existsSync - vi.mocked(fs.existsSync).mockReturnValue(false); - - // Default mock for statSync - vi.mocked(fs.statSync).mockReturnValue({ - isDirectory: () => true, - } as unknown as fs.Stats); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should return project config file path in current directory', () => { - // Mock getProjectSettingsDir to return the project dir - vi.mocked(getProjectSettingsDir).mockReturnValue(mockProjectDir); - - const result = getProjectConfigFile(); - - expect(result).toBe(expectedConfigFile); - }); - - it('should create project directory if it does not exist', () => { - // Mock getProjectSettingsDir to return the project dir - vi.mocked(getProjectSettingsDir).mockReturnValue(mockProjectDir); - - // Mock directory does not exist - vi.mocked(fs.existsSync).mockReturnValue(false); - - getProjectConfigFile(); - - // Verify directory creation was attempted - expect(fs.mkdirSync).toHaveBeenCalledWith(mockProjectDir, { - recursive: true, - }); - }); - - it('should not create project directory if it already exists', () => { - // Mock getProjectSettingsDir to return the project dir - vi.mocked(getProjectSettingsDir).mockReturnValue(mockProjectDir); - - // Mock directory already exists - vi.mocked(fs.existsSync).mockReturnValue(true); - - getProjectConfigFile(); - - // Verify directory creation was not attempted - expect(fs.mkdirSync).not.toHaveBeenCalled(); - }); - - it('should return empty string if project directory cannot be determined', () => { - // Mock getProjectSettingsDir to return empty string (error case) - vi.mocked(getProjectSettingsDir).mockReturnValue(''); - - const result = getProjectConfigFile(); - - expect(result).toBe(''); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9704ca..1fcbac0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: chalk: specifier: ^5 version: 5.4.1 + cosmiconfig: + specifier: ^9.0.0 + version: 9.0.0(typescript@5.8.2) deepmerge: specifier: ^4.3.1 version: 4.3.1