cligentic
← back to catalog
cligentic block

config

Profile-aware JSON config loader. Reads from the app's config directory with multi-profile support (--profile production). Merge precedence: defaults < profile overrides.

Install
config demo
NPM dependencies

zero deps — pure TS

Block dependencies
Size

92 LOC

What it does

Loads JSON config from your app's config directory with support for named profiles. A --profile production flag swaps the active config without multiple config files.

Quickstart

import { getAppPaths } from "@/cli/foundation/xdg-paths";
import { loadConfig, saveConfig } from "@/cli/foundation/config";
 
type MyConfig = {
  apiUrl: string;
  timeout: number;
};
 
const paths = getAppPaths("myapp");
const config = loadConfig<MyConfig>(paths.config, "production");
// config.apiUrl, config.timeout

Config file shape

{
  "defaults": {
    "apiUrl": "https://api.example.com",
    "timeout": 5000
  },
  "profiles": {
    "production": {
      "apiUrl": "https://api.prod.example.com",
      "timeout": 10000
    },
    "staging": {
      "apiUrl": "https://api.staging.example.com"
    }
  }
}

Merge order: defaults then profile overrides on top. Missing keys in the profile fall back to defaults.

API

loadConfig<T>(configDir, profile?): T
saveConfig<T>(configDir, update): void
listProfiles(configDir): string[]

saveConfig merges into the existing file using atomic-write. Existing profiles you don't touch are preserved.

Pair with

Source

The full file, 92 lines.

This is the exact TypeScript that lands in your project when you run the install command. Read it, copy it, edit it, own it. cligentic never touches it again.

src/cli/foundation/config.ts
// cligentic block: config
//
// Profile-aware config loader. Reads JSON config from the app's config
// directory with support for multiple profiles (dev, staging, production).
//
// Precedence: env vars > CLI flags > profile config > default config.
//
// Usage:
//   import { loadConfig, saveConfig } from "./foundation/config";
//
//   const config = loadConfig<MyConfig>(paths.config, "production");
//   // reads ~/.config/myapp/config.json, merges profile "production"

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteJson } from "./atomic-write";

export type ConfigFile<T> = {
  defaults?: Partial<T>;
  profiles?: Record<string, Partial<T>>;
};

/**
 * Loads config from a JSON file at {configDir}/config.json.
 * Merges: defaults <- profile overrides <- env overrides.
 *
 * Returns the defaults if no config file exists (never throws for missing file).
 */
export function loadConfig<T extends Record<string, unknown>>(
  configDir: string,
  profile?: string,
): T {
  const filePath = join(configDir, "config.json");

  let file: ConfigFile<T> = {};
  if (existsSync(filePath)) {
    try {
      file = JSON.parse(readFileSync(filePath, "utf8"));
    } catch {
      // corrupted config, start fresh
    }
  }

  const defaults = (file.defaults ?? {}) as T;
  const profileOverrides = profile && file.profiles?.[profile]
    ? file.profiles[profile]
    : {};

  return { ...defaults, ...profileOverrides } as T;
}

/**
 * Saves config to {configDir}/config.json using atomic write.
 * Merges the update into the existing file (preserves other profiles).
 */
export function saveConfig<T extends Record<string, unknown>>(
  configDir: string,
  update: ConfigFile<T>,
): void {
  const filePath = join(configDir, "config.json");

  let existing: ConfigFile<T> = {};
  if (existsSync(filePath)) {
    try {
      existing = JSON.parse(readFileSync(filePath, "utf8"));
    } catch {
      // corrupted, overwrite
    }
  }

  const merged: ConfigFile<T> = {
    defaults: { ...existing.defaults, ...update.defaults } as Partial<T>,
    profiles: { ...existing.profiles, ...update.profiles },
  };

  atomicWriteJson(filePath, merged);
}

/**
 * Lists available profile names from the config file.
 */
export function listProfiles(configDir: string): string[] {
  const filePath = join(configDir, "config.json");
  if (!existsSync(filePath)) return [];
  try {
    const file = JSON.parse(readFileSync(filePath, "utf8"));
    return Object.keys(file.profiles ?? {});
  } catch {
    return [];
  }
}