TL;DR
cosmiconfig is the standard for tool configuration discovery — it's what ESLint, Prettier, Babel, PostCSS, and hundreds of other tools use to find config files (.eslintrc, .prettierrc, eslint.config.js, etc.). lilconfig is cosmiconfig's lighter replacement — same API, no YAML support by default, 30% smaller. conf is for a different use case — persistent user preferences for CLI apps and desktop tools, stored in the OS's app data directory with JSON Schema validation and optional encryption. For finding project config files: lilconfig. For storing user settings between runs: conf.
Key Takeaways
- cosmiconfig: ~80M weekly downloads — the config discovery standard (ESLint/Prettier/Babel use it)
- lilconfig: ~80M weekly downloads — drop-in replacement, no yaml dependency, slightly smaller
- conf: ~2M weekly downloads — persistent user settings, not config file discovery
- cosmiconfig and lilconfig solve "find the user's config for my tool" — lilconfig is now preferred
- conf solves "store user preferences between CLI runs" — completely different use case
- For TypeScript config files (
.eslintrc.ts), both supportconfigFileswith dynamic import
Download Trends
| Package | Weekly Downloads | Use Case | YAML | TypeScript Config | Persistent Storage |
|---|---|---|---|---|---|
cosmiconfig | ~80M | Config discovery | ✅ | ✅ | ❌ |
lilconfig | ~80M | Config discovery | ❌ (opt-in) | ✅ | ❌ |
conf | ~2M | App settings | N/A | N/A | ✅ |
cosmiconfig
cosmiconfig — the config discovery standard:
How config discovery works
cosmiconfig searches for config in this order (example for "mytool"):
1. package.json → "mytool" key
2. .mytoolrc (JSON or YAML)
3. .mytoolrc.json
4. .mytoolrc.yaml / .mytoolrc.yml
5. .mytoolrc.js / .mytoolrc.cjs / .mytoolrc.mjs
6. .mytoolrc.ts / .mytoolrc.cts / .mytoolrc.mts
7. mytool.config.js / mytool.config.cjs / mytool.config.mjs
8. mytool.config.ts / mytool.config.cts / mytool.config.mts
Searches in current directory, then parent directories, up to root.
Basic usage
import { cosmiconfig } from "cosmiconfig"
// Find config for "pkgpulse" tool:
const explorer = cosmiconfig("pkgpulse")
// Search from current directory up:
const result = await explorer.search()
if (result) {
console.log(result.config) // The loaded config object
console.log(result.filepath) // "/path/to/project/.pkgpulserc.json"
console.log(result.isEmpty) // true if file was empty
} else {
console.log("No config found — using defaults")
}
// Load from a specific path:
const specific = await explorer.load("/path/to/pkgpulse.config.js")
Config file examples
// .pkgpulserc.json
{
"threshold": 80,
"ignore": ["@types/*", "eslint-*"],
"reportFormat": "json",
"autoFix": true
}
# .pkgpulserc.yaml
threshold: 80
ignore:
- "@types/*"
- "eslint-*"
reportFormat: json
autoFix: true
// pkgpulse.config.js
export default {
threshold: 80,
ignore: ["@types/*", "eslint-*"],
reportFormat: "json",
autoFix: true,
}
// pkgpulse.config.ts
import type { PkgPulseConfig } from "@pkgpulse/sdk"
const config: PkgPulseConfig = {
threshold: 80,
ignore: ["@types/*", "eslint-*"],
reportFormat: "json",
autoFix: true,
}
export default config
// package.json — "pkgpulse" key:
{
"name": "my-project",
"pkgpulse": {
"threshold": 80,
"ignore": ["@types/*"]
}
}
Custom loaders
import { cosmiconfig } from "cosmiconfig"
import { parse } from "smol-toml"
import { readFile } from "fs/promises"
// Add TOML support:
const explorer = cosmiconfig("mytool", {
loaders: {
".toml": async (filepath) => {
const content = await readFile(filepath, "utf8")
return parse(content)
},
},
searchPlaces: [
"package.json",
".mytoolrc",
".mytoolrc.json",
".mytoolrc.yaml",
".mytoolrc.toml", // Add TOML
"mytool.config.js",
"mytool.config.ts",
],
})
Cache and sync API
import { cosmiconfig, cosmiconfigSync } from "cosmiconfig"
// Sync version (for build tools where async isn't available):
const explorerSync = cosmiconfigSync("mytool")
const result = explorerSync.searchSync()
// Caching — cosmiconfig caches results by default:
const explorer = cosmiconfig("mytool")
// First call — reads file system:
const result1 = await explorer.search()
// Subsequent calls — from cache:
const result2 = await explorer.search() // No file I/O
// Clear cache if files might have changed:
explorer.clearCaches()
lilconfig
lilconfig — the lighter cosmiconfig alternative:
Why lilconfig?
// lilconfig removes the yaml dependency from cosmiconfig:
// cosmiconfig: ~400KB (includes js-yaml)
// lilconfig: ~100KB (no yaml by default)
// API is 1:1 compatible with cosmiconfig:
import { lilconfig } from "lilconfig"
const explorer = lilconfig("pkgpulse")
const result = await explorer.search()
// Same result object:
// { config, filepath, isEmpty }
Add YAML support to lilconfig
import { lilconfig } from "lilconfig"
import { load as yamlLoad } from "js-yaml"
import { readFile } from "fs/promises"
// Opt-in to YAML (unlike cosmiconfig, not default):
const explorer = lilconfig("pkgpulse", {
loaders: {
".yaml": async (filepath) => {
const content = await readFile(filepath, "utf8")
return yamlLoad(content)
},
".yml": async (filepath) => {
const content = await readFile(filepath, "utf8")
return yamlLoad(content)
},
},
})
Migration from cosmiconfig
// Before:
import { cosmiconfig } from "cosmiconfig"
const explorer = cosmiconfig("mytool")
// After (drop-in replacement):
import { lilconfig } from "lilconfig"
const explorer = lilconfig("mytool")
// No other changes needed for the common case.
// If you use YAML configs, add the yaml loader as shown above.
conf
conf — persistent user settings for CLI tools and apps:
Basic usage
import Conf from "conf"
// Define schema and store:
const store = new Conf({
projectName: "pkgpulse-cli",
schema: {
apiKey: { type: "string" },
defaultThreshold: { type: "number", default: 80, minimum: 0, maximum: 100 },
ignoredPackages: {
type: "array",
items: { type: "string" },
default: [],
},
outputFormat: {
type: "string",
enum: ["json", "table", "minimal"],
default: "table",
},
},
})
// Get and set settings:
store.set("apiKey", "pk_live_abc123")
console.log(store.get("apiKey")) // "pk_live_abc123"
store.set("defaultThreshold", 85)
console.log(store.get("defaultThreshold")) // 85
// Delete:
store.delete("apiKey")
// Clear all:
store.clear()
Where conf stores data
import Conf from "conf"
const store = new Conf({ projectName: "pkgpulse-cli" })
console.log(store.path)
// macOS: ~/Library/Preferences/pkgpulse-cli/config.json
// Linux: ~/.config/pkgpulse-cli/config.json
// Windows: %APPDATA%\pkgpulse-cli\Config\config.json
// Full config object:
console.log(store.store)
// { apiKey: "pk_live_...", defaultThreshold: 85, ... }
Encrypted secrets
import Conf from "conf"
const store = new Conf({
projectName: "pkgpulse-cli",
encryptionKey: process.env.ENCRYPTION_KEY, // Must be consistent
schema: {
apiKey: { type: "string" },
},
})
// apiKey is stored encrypted — not readable if you open the JSON file
store.set("apiKey", "pk_live_secret123")
CLI workflow (login/logout)
import Conf from "conf"
const store = new Conf({ projectName: "pkgpulse-cli" })
// pkgpulse login command:
export async function login(apiKey: string) {
// Validate key:
const response = await fetch("https://api.pkgpulse.com/me", {
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!response.ok) throw new Error("Invalid API key")
store.set("apiKey", apiKey)
const { email } = await response.json()
console.log(`Logged in as ${email}`)
}
// pkgpulse logout command:
export function logout() {
store.delete("apiKey")
console.log("Logged out")
}
// pkgpulse check command — use stored API key:
export async function checkPackage(name: string) {
const apiKey = store.get("apiKey")
if (!apiKey) throw new Error("Not logged in. Run: pkgpulse login")
const response = await fetch(`https://api.pkgpulse.com/packages/${name}`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
return response.json()
}
Feature Comparison
| Feature | cosmiconfig | lilconfig | conf |
|---|---|---|---|
| Config file discovery | ✅ | ✅ | ❌ |
| YAML support | ✅ Built-in | ❌ Optional | N/A |
| TypeScript configs | ✅ | ✅ | N/A |
| Persistent storage | ❌ | ❌ | ✅ |
| JSON Schema validation | ❌ | ❌ | ✅ |
| Encryption | ❌ | ❌ | ✅ |
| Cross-platform paths | N/A | N/A | ✅ |
| Bundle size | ~400KB | ~100KB | ~200KB |
| ESM | ✅ | ✅ | ✅ |
| CJS | ✅ | ✅ | ✅ |
When to Use Each
Choose lilconfig if:
- Building a CLI tool or framework that needs user-configurable settings
- You want the cosmiconfig discovery pattern — this is the modern choice
- YAML config file support is not needed (or you add it explicitly)
- Slightly smaller bundle than cosmiconfig matters
Choose cosmiconfig if:
- Existing tool or code already uses it
- You need YAML out of the box without adding loaders
- You want the reference implementation with most features
Choose conf if:
- Building a CLI tool that needs to remember user authentication/preferences
- API keys, tokens, user settings that persist between runs
- You want OS-appropriate config paths (not just
.config/in the project directory) - JSON Schema validation and optional encryption of sensitive values
Don't use either if:
- You just need environment variables — use
dotenvort3-envinstead - You need a single config file in a fixed location — just
fs.readFile+JSON.parse
How Config Discovery Caching Affects Tool Performance
Both cosmiconfig and lilconfig cache search and load results by default, but the caching strategy has implications that matter in long-running processes and watch mode build tools.
The cache operates at the explorer instance level: a single cosmiconfig('mytool') explorer caches every file it has searched and every config it has loaded. This means in a webpack or Vite plugin that loads config for a tool at build startup, the file system is only read once per build — subsequent calls to explorer.search() return the cached result immediately. This is the expected behavior and works well for most use cases.
The problem arises in watch mode or long-lived processes where config files can change while the process is running. If a developer edits their .pkgpulserc.json while vite dev is running, the plugin's cosmiconfig explorer will still return the old cached version until you call explorer.clearCaches() or create a new explorer instance. Build tools that want to support config hot-reloading must explicitly clear the cosmiconfig cache when they detect a relevant file change via their own file watcher. ESLint handles this by using cosmiconfig's clearCaches() in its --watch mode when it detects configuration file changes.
lilconfig's cache works identically, since it shares the same caching implementation logic. One lilconfig-specific behavior: because it drops the YAML dependency, a project that adds a .toolrc.yaml file while lilconfig is running (and yaml loaders are not configured) will get a search result of null rather than an error about the yaml loader — which can cause confusing silent fallback to defaults. This is worth documenting in your tool's error messages if you don't support YAML.
Building a CLI Tool with conf: Schema Evolution and Migrations
conf stores settings as a JSON file in the OS data directory, which means you need a strategy for handling schema changes across versions of your CLI tool. When users upgrade from v1 to v2 of your tool and the settings schema changes, conf provides a migrations option to handle this automatically.
The migrations feature lets you define version-keyed transformation functions that run when conf detects the stored config version is older than the current one. Each migration receives the store and can transform the data in place. This is similar in concept to database migrations but applied to user preferences. For example, if v1 stored outputFormat: 'json' and v2 renames the field to reportFormat, you write a migration for 2.0.0 that reads store.get('outputFormat') and writes store.set('reportFormat', ...) before deleting the old key.
The projectVersion option ties the migration logic to your tool's npm package version. conf reads it, compares to the last recorded version in the settings file, and runs any migrations for versions between the two. This requires consistent versioning in your package.json — a good practice in any case.
For CLI tools that manage credentials (API keys, tokens, OAuth refresh tokens), conf's encryptionKey option is important but comes with a key-management responsibility. The encryption key must be stable across invocations — if it changes, all stored encrypted values become unreadable. Common approaches include deriving the key from the user's system identifier (machine ID, username combined with application name), reading it from an environment variable, or using the OS keychain via a separate keytar integration. The simplest secure approach for most CLIs is to leave credentials unencrypted but protect the file with restrictive permissions (conf does this automatically on macOS and Linux: mode 0600).
When to Use Neither: Environment Variables and Flat Config Files
Understanding the limits of config discovery helps you avoid over-engineering configuration loading for simpler cases.
For server applications deployed to cloud infrastructure (Vercel, Railway, Fly.io, Docker), environment variables are the correct mechanism for all runtime configuration. These platforms provide first-class environment variable management with per-environment overrides, secret management, and audit logs. Using cosmiconfig or lilconfig to find a .apprc.json in a serverless function's file system is technically possible but adds unnecessary complexity and fragility — the file may not be present in the deployed container's working directory, and environment variables are available by convention.
For tools with a single, well-known config file location (like tsconfig.json, .gitignore, or .npmrc), a simple fs.readFile + JSON.parse is more appropriate than cosmiconfig's hierarchical search. The multi-directory traversal is valuable when you want your tool to respect nested project structures — finding the nearest .eslintrc to the file being linted rather than always using the project root. If your tool's config always lives at the project root and users would never put it in a subdirectory, the traversal overhead is unnecessary complexity.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on cosmiconfig v9.x, lilconfig v3.x, and conf v13.x.
Compare developer tool and configuration packages on PkgPulse →
See also: cac vs meow vs arg 2026 and unimport vs unplugin-auto-import vs babel-plugin-auto-import: Auto-Importing in JavaScript 2026, archiver vs adm-zip vs JSZip (2026).