cligentic
← back to catalog
cligentic block

xdg-paths

XDG Base Directory Spec resolver with macOS (~/Library) and Windows (%APPDATA%) fallbacks. Gives your CLI canonical config/state/cache/audit/sessions paths. APP_HOME env override for testing.

Install
xdg-paths demo
NPM dependencies

zero deps — pure TS

Block dependencies

standalone

Size

135 LOC

The problem

Every CLI invents ~/.myapp/ from scratch. Some use ~/.config/myapp, others use ~/Library/Application Support/myapp on macOS, others just dump everything in ~/.myapp. None of them respect XDG_CONFIG_HOME or handle Windows %APPDATA% correctly.

The xdg-paths block resolves canonical directories following the XDG Base Directory Spec on Linux, with proper macOS and Windows fallbacks.

Quickstart

import { getAppPaths, ensureHome } from "@/cli/foundation/xdg-paths";
 
const paths = getAppPaths("myapp");
ensureHome(paths);
 
// paths.config   = ~/.config/myapp          (Linux)
// paths.state    = ~/.local/state/myapp     (Linux)
// paths.cache    = ~/.cache/myapp           (Linux)
// paths.audit    = ~/.local/state/myapp/audit
// paths.sessions = ~/.config/myapp/sessions (mode 0o700)
// paths.tmp      = ~/.cache/myapp/tmp

Platform behavior

  • Linux/WSL: follows XDG spec. Respects XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_CACHE_HOME.
  • macOS: ~/Library/Application Support/myapp for config+state, ~/Library/Caches/myapp for cache.
  • Windows: %APPDATA%/myapp for config, %LOCALAPPDATA%/myapp for state+cache.
  • Override: set MYAPP_HOME (app name uppercased, hyphens to underscores) to override all paths to a single root. Useful for testing and CI.

The paths

PathPurposeLinux default
configTOML/JSON config files~/.config/myapp
stateSessions, pending, killswitch~/.local/state/myapp
cacheTemp data safe to delete~/.cache/myapp
homeAlias for config~/.config/myapp
auditAudit log JSONL files~/.local/state/myapp/audit
sessionsAuth tokens (mode 0o700)~/.config/myapp/sessions
tmpTemp files for atomic writes~/.cache/myapp/tmp

Pair with other blocks

Source

The full file, 135 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/xdg-paths.ts
// cligentic block: xdg-paths
//
// XDG Base Directory Spec resolver with macOS and Windows fallbacks.
// Gives your CLI a canonical directory layout instead of inventing
// ~/.myapp/ from scratch every time.
//
// Design rules:
//   1. Respect XDG env vars on Linux (XDG_CONFIG_HOME, XDG_STATE_HOME, etc).
//   2. Fall back to platform conventions: ~/Library on macOS, %APPDATA% on Windows.
//   3. Provide an APP_HOME env var override for testing.
//   4. Create directories lazily (only when ensureHome is called).
//   5. Pure functions, no side effects except ensureHome.
//
// Usage:
//   import { getAppPaths, ensureHome } from "./foundation/xdg-paths";
//
//   const paths = getAppPaths("myapp");
//   // paths.config  = ~/.config/myapp      (Linux)
//   // paths.state   = ~/.local/state/myapp  (Linux)
//   // paths.cache   = ~/.cache/myapp        (Linux)
//   // paths.home    = ~/.config/myapp       (Linux, alias for config)
//
//   ensureHome(paths);  // creates all directories

import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { platform } from "node:os";

export type AppPaths = {
  /** Primary config directory. TOML/JSON config files live here. */
  config: string;
  /** State directory. Sessions, pending approvals, killswitch file. */
  state: string;
  /** Cache directory. Temporary data safe to delete. */
  cache: string;
  /** Alias for config. The "home" of your CLI's persistent data. */
  home: string;
  /** Audit logs directory. Append-only JSONL files. */
  audit: string;
  /** Sessions directory. Auth tokens, refresh tokens. */
  sessions: string;
  /** Temporary directory for atomic writes. */
  tmp: string;
};

/**
 * Resolves the canonical directory paths for your CLI app.
 *
 * On Linux: follows XDG Base Directory Spec.
 * On macOS: uses ~/Library/Application Support (config/state) and ~/Library/Caches.
 * On Windows: uses %APPDATA% (config) and %LOCALAPPDATA% (state/cache).
 *
 * The APP_HOME env var (e.g., MYAPP_HOME) overrides everything, useful for
 * testing and CI where you don't want to pollute the real home directory.
 */
export function getAppPaths(appName: string): AppPaths {
  const envKey = `${appName.toUpperCase().replace(/-/g, "_")}_HOME`;
  const override = process.env[envKey];

  if (override) {
    return buildPaths(override);
  }

  const os = platform();
  const home = homedir();

  if (os === "darwin") {
    const appSupport = join(home, "Library", "Application Support", appName);
    return {
      config: appSupport,
      state: appSupport,
      cache: join(home, "Library", "Caches", appName),
      home: appSupport,
      audit: join(appSupport, "audit"),
      sessions: join(appSupport, "sessions"),
      tmp: join(appSupport, "tmp"),
    };
  }

  if (os === "win32") {
    const appData = process.env.APPDATA || join(home, "AppData", "Roaming");
    const localAppData = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
    const configDir = join(appData, appName);
    return {
      config: configDir,
      state: join(localAppData, appName),
      cache: join(localAppData, appName, "cache"),
      home: configDir,
      audit: join(configDir, "audit"),
      sessions: join(configDir, "sessions"),
      tmp: join(localAppData, appName, "tmp"),
    };
  }

  // Linux / WSL / other Unix: XDG
  const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config");
  const xdgState = process.env.XDG_STATE_HOME || join(home, ".local", "state");
  const xdgCache = process.env.XDG_CACHE_HOME || join(home, ".cache");
  const configDir = join(xdgConfig, appName);

  return {
    config: configDir,
    state: join(xdgState, appName),
    cache: join(xdgCache, appName),
    home: configDir,
    audit: join(xdgState, appName, "audit"),
    sessions: join(configDir, "sessions"),
    tmp: join(xdgCache, appName, "tmp"),
  };
}

/**
 * Creates all directories in the AppPaths tree. Idempotent.
 * Permissions: 0o700 for sensitive dirs (sessions), 0o755 for the rest.
 */
export function ensureHome(paths: AppPaths): void {
  for (const dir of [paths.config, paths.state, paths.cache, paths.audit, paths.tmp]) {
    mkdirSync(dir, { recursive: true, mode: 0o755 });
  }
  mkdirSync(paths.sessions, { recursive: true, mode: 0o700 });
}

function buildPaths(root: string): AppPaths {
  return {
    config: root,
    state: root,
    cache: join(root, "cache"),
    home: root,
    audit: join(root, "audit"),
    sessions: join(root, "sessions"),
    tmp: join(root, "tmp"),
  };
}