Every CLI tool eventually needs configuration. Users want defaults they don't have to type every time. Teams want shared settings across projects. CI pipelines want declarative configs checked into version control.
But getting config right is surprisingly tricky. Where should the config file live? What format should it use? How do you merge file config with CLI flags? What about environment variables?
This article shows you how to build a robust config system that handles all of these cases — using patterns borrowed from ESLint, Prettier, and other battle-tested CLI tools.
The Config Hierarchy
Good CLI tools resolve configuration from multiple sources, in priority order:
-
CLI flags (highest priority) —
--threshold 90 -
Environment variables —
MYTOOL_THRESHOLD=90 -
Local config file —
.mytoolrc.jsonin the project -
User config file —
~/.config/mytool/config.json - Built-in defaults (lowest priority)
Each level overrides the one below it. This lets users set sensible defaults while allowing per-project and per-command overrides.
Step 1: Config File Discovery
Follow the pattern used by ESLint and Prettier — walk up the directory tree looking for config files:
// lib/config.js
import { readFile, access } from 'node:fs/promises';
import { join, dirname, parse } from 'node:path';
import { homedir } from 'node:os';
const CONFIG_NAMES = [
'.mytoolrc',
'.mytoolrc.json',
'.mytoolrc.yaml',
'.mytoolrc.yml',
'mytool.config.json',
'mytool.config.js',
];
async function findConfigFile(startDir) {
let dir = startDir;
while (true) {
for (const name of CONFIG_NAMES) {
const filePath = join(dir, name);
try {
await access(filePath);
return filePath;
} catch {}
}
// Also check package.json for a "mytool" key
try {
const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf-8'));
if (pkg.mytool) {
return { embedded: true, config: pkg.mytool, source: join(dir, 'package.json') };
}
} catch {}
const parent = dirname(dir);
if (parent === dir) break; // Reached root
dir = parent;
}
return null;
}
This approach means your tool automatically picks up the right config regardless of which subdirectory the user runs it from — just like git finds .gitignore files.
Step 2: Parse Multiple Formats
Support JSON, YAML, and JavaScript configs:
async function parseConfigFile(filePath) {
if (typeof filePath === 'object' && filePath.embedded) {
return { config: filePath.config, source: filePath.source };
}
const content = await readFile(filePath, 'utf-8');
const ext = filePath.split('.').pop();
let config;
if (ext === 'json' || filePath.endsWith('.mytoolrc')) {
try {
config = JSON.parse(content);
} catch (e) {
// Try JSONC (JSON with comments)
const stripped = content.replace(/\/\/.*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
config = JSON.parse(stripped);
}
} else if (ext === 'yaml' || ext === 'yml') {
// Minimal YAML parsing for simple configs
config = parseSimpleYaml(content);
} else if (ext === 'js' || ext === 'mjs') {
const module = await import(filePath);
config = module.default || module;
}
return { config, source: filePath };
}
function parseSimpleYaml(content) {
const result = {};
for (const line of content.split('\n')) {
const match = line.match(/^(\w+):\s*(.+)/);
if (match) {
let value = match[2].trim();
if (value === 'true') value = true;
else if (value === 'false') value = false;
else if (/^\d+$/.test(value)) value = parseInt(value);
else if (/^\d+\.\d+$/.test(value)) value = parseFloat(value);
else value = value.replace(/^['"]|['"]$/g, '');
result[match[1]] = value;
}
}
return result;
}
Step 3: Environment Variable Mapping
Map environment variables to config keys with a consistent prefix:
function getEnvConfig(prefix = 'MYTOOL') {
const config = {};
for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith(`${prefix}_`)) continue;
const configKey = key
.slice(prefix.length + 1)
.toLowerCase()
.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
// Type coercion
if (value === 'true') config[configKey] = true;
else if (value === 'false') config[configKey] = false;
else if (/^\d+$/.test(value)) config[configKey] = parseInt(value);
else config[configKey] = value;
}
return config;
}
This converts MYTOOL_MAX_ERRORS=5 to { maxErrors: 5 } automatically.
Step 4: Merge Everything Together
const DEFAULTS = {
threshold: 80,
maxErrors: 0,
format: 'text',
verbose: false,
timeout: 30000,
};
export async function resolveConfig(cliOptions = {}) {
// 1. Start with defaults
let config = { ...DEFAULTS };
// 2. User-level config (~/.config/mytool/config.json)
try {
const userConfigPath = join(homedir(), '.config', 'mytool', 'config.json');
const userConfig = JSON.parse(await readFile(userConfigPath, 'utf-8'));
config = { ...config, ...userConfig };
} catch {}
// 3. Project-level config (walk up directories)
const configResult = await findConfigFile(process.cwd());
if (configResult) {
const { config: fileConfig, source } = await parseConfigFile(configResult);
config = { ...config, ...fileConfig };
config._source = source;
}
// 4. Environment variables
const envConfig = getEnvConfig();
config = { ...config, ...envConfig };
// 5. CLI flags (highest priority)
// Remove undefined values from CLI options
const cleanCli = Object.fromEntries(
Object.entries(cliOptions).filter(([_, v]) => v !== undefined)
);
config = { ...config, ...cleanCli };
return config;
}
Step 5: Config Validation
Don't trust user config — validate it:
const SCHEMA = {
threshold: { type: 'number', min: 0, max: 100 },
maxErrors: { type: 'number', min: 0 },
format: { type: 'string', enum: ['text', 'json', 'table'] },
verbose: { type: 'boolean' },
timeout: { type: 'number', min: 1000, max: 300000 },
};
export function validateConfig(config) {
const errors = [];
for (const [key, rules] of Object.entries(SCHEMA)) {
const value = config[key];
if (value === undefined) continue;
if (typeof value !== rules.type) {
errors.push(`"${key}" must be a ${rules.type}, got ${typeof value}`);
continue;
}
if (rules.min !== undefined && value < rules.min) {
errors.push(`"${key}" must be >= ${rules.min}, got ${value}`);
}
if (rules.max !== undefined && value > rules.max) {
errors.push(`"${key}" must be <= ${rules.max}, got ${value}`);
}
if (rules.enum && !rules.enum.includes(value)) {
errors.push(`"${key}" must be one of: ${rules.enum.join(', ')}`);
}
}
return errors;
}
Step 6: Generate Config Files
Help users create config files with an init command:
program
.command('init')
.description('Create a config file')
.option('--format <format>', 'Config format: json, yaml', 'json')
.action(async (options) => {
const filename = options.format === 'yaml' ? '.mytoolrc.yaml' : '.mytoolrc.json';
const content = options.format === 'yaml'
? `threshold: 80\nmaxErrors: 0\nformat: text\nverbose: false\n`
: JSON.stringify(DEFAULTS, null, 2) + '\n';
await writeFile(filename, content);
console.log(chalk.green(`Created ${filename}`));
});
Using the Config System
program
.command('audit <url>')
.option('-t, --threshold <n>', 'Minimum score', parseInt)
.option('--format <fmt>', 'Output format')
.option('-v, --verbose', 'Verbose output')
.action(async (url, cliOptions) => {
const config = await resolveConfig(cliOptions);
const errors = validateConfig(config);
if (errors.length > 0) {
errors.forEach(e => console.error(chalk.red(` Config error: ${e}`)));
if (config._source) {
console.error(chalk.gray(` Config loaded from: ${config._source}`));
}
process.exit(2);
}
if (config.verbose) {
console.error(chalk.gray(` Config: ${JSON.stringify(config, null, 2)}`));
}
// Use config.threshold, config.format, etc.
});
Conclusion
A well-designed config system makes your CLI tool feel professional. Users can set it and forget it, teams can share configs, and CI pipelines can work declaratively. The hierarchy pattern — defaults → user config → project config → env vars → CLI flags — is the same one used by ESLint, Prettier, and every other tool developers trust.
Wilson Xu builds Node.js CLI tools focused on developer experience. Find his work at dev.to/chengyixu and npm.
Top comments (0)