[READ-ONLY] Mirror of https://github.com/bombshell-dev/configliere. A statically typed entry-point router for command-line applications
TypeScript 100%

README.md

Configliere #

A statically typed entry-point router for command-line applications.

An argument parser tells you what the user typed. Configliere tells you where and how they intend to enter your program. For execution, it marshals a validated, statically typed model for that entry point.

Configliere matches input to an intent: a method at an application-relative route, such as:

HELP    /
VERSION /
EXECUTE /serve
HELP    /database/clean
EXECUTE /database/clean

Every reachable intent appears in the result type. Narrow method and route, and TypeScript knows the exact model available at that entry point.

Configliere is not a CLI framework. It does not own handlers, effects, output, or process lifetime. It is not a CLI parser whose product is a bag of flags. Its product is a typed intent; your application decides what that intent does.

input → bind and expand phases → resolve route + method → intent

Define every way into the program #

route() declares an address. command() declares an executable route. Every route supports help; version and execution exist only where they are explicitly added.

Definitions are immutable composition pipelines, not handler registrations:

import {
  command,
  description,
  name,
  option,
  route,
  routes,
  schema,
  toggle,
  version,
} from "@frontside/configliere";
import * as z from "zod";

export const app = command(
  name("simulacrum"),
  description("Run and manage local service simulators."),
  version("1.0.0"),
  toggle(name("verbose")),
  routes(
    command(
      name("serve"),
      option(name("port"), schema(z.number().default(4000))),
    ),
    route(
      name("database"),
      routes(
        command(
          name("clean"),
          toggle(name("dryRun")),
        ),
      ),
    ),
  ),
);

That definition makes these entry points—and no others—reachable:

HELP /                  VERSION /              EXECUTE /
HELP /serve                                    EXECUTE /serve
HELP /database
HELP /database/clean                           EXECUTE /database/clean

The root name identifies the executable; it is not repeated in route IDs. simulacrum serve therefore selects /serve, not /simulacrum/serve.

Route an intent #

parse() returns a discriminated union of the reachable entry points. Dispatch can stay flat even when the route tree is deep:

import process from "node:process";
import {
  parse,
  printErrors,
  printHelp,
  printVersion,
} from "@frontside/configliere";
import { app } from "./app.ts";

const result = parse(app, { argv: process.argv.slice(2) });

if (!result.ok) {
  console.error(printErrors(result));
  process.exit(1);
}

switch (result.method) {
  case "help":
    console.log(printHelp(result));
    break;

  case "version":
    console.log(printVersion(result));
    break;

  case "execute":
    switch (result.route) {
      case "/":
        result.model.verbose; // boolean
        break;

      case "/serve":
        result.model.port; // number
        result.models["/"].verbose; // boolean
        break;

      case "/database/clean":
        result.model.dryRun; // boolean
        result.models["/"]; // { verbose: boolean }
        result.models["/database"]; // {}
        break;
    }
}

An execute intent has two views of configuration:

  • model is owned by the selected route.
  • models contains the statically typed model for every route along the selected path. Sibling routes are absent from both the value and its type.
Invocation Intent and model
simulacrum --verbose EXECUTE / with { verbose: true }
simulacrum serve --port 4100 EXECUTE /serve with { port: 4100 }
simulacrum database clean --dry-run EXECUTE /database/clean with { dryRun: true }
simulacrum --help database clean HELP /database/clean; controls target the deepest selected route
simulacrum database method-not-allowed; /database does not support execution
simulacrum serve --port nope unprocessable-content; invalid data never reaches the application

Command literals are routing tokens, not positional arguments. As route segments become discoverable, Configliere scopes parameter binding to the segment that owns each token. This makes identical option names on parent and child routes unambiguous.

Marshal configuration into the route #

CLI arguments are only one source. Configliere can marshal JavaScript values and flat environment records into the same route-local models:

const result = parse(app, {
  argv: process.argv.slice(2),
  values: [{
    name: "config.json",
    value: { serve: { port: 4200 } },
  }],
  envs: [{
    name: "process",
    value: process.env,
  }],
});

For /serve, serve.port and SERVE_PORT both address its port parameter. CLI text and environment text are decoded; JavaScript values are used directly. The resulting value is then validated by its Standard Schema schema. Zod, ArkType, Valibot, and other conforming libraries work without adapters.

Source precedence is explicit:

CLI → environment → JavaScript values → schema default

Pause without surrendering the type system #

Sometimes the route cannot be fully configured, or even fully discovered, until the application performs I/O. Configliere can pause at a typed checkpoint and resume with the result.

Dynamic phases serve two common cases:

  • Load a configuration file, then use its contents as value sources for later parameters.
  • Load plugins, then extend the route graph with their options and routes.

checkpoint() is the configuration-file convenience; dynamic() is the general route-extension mechanism. Both keep I/O in the application while preserving the exact type of what parsing can produce next.

Help and version cross checkpoints #

--help and --version request methods; they do not settle an intent or bypass parsing. Configliere cannot produce either intent until it knows the deepest selected route. A dynamic phase may introduce that route, its options, or its version.

The driver must therefore resume every increment until parsing returns an intent—even when the arguments contain --help or --version. This applies recursively when one continuation exposes another increment.

app --config app.json auth0 --help
  → bind the configuration phase
  → load configuration and resume
  → discover /auth0
  → HELP /auth0

Help and version are not escape hatches around configuration loading. Do not inspect argv to skip a checkpoint. A phase may be required by HELP, VERSION, or EXECUTE, so its driver work must be safe for all three: return loading and validation failures as Result issues, avoid command side effects, and defer execution until an EXECUTE intent. If discovery fails, report that failure rather than printing incomplete help for an unresolved route graph. Routes without dynamic phases still resolve directly; the rule is to stop only at an intent or failure, never merely because the arguments look informational.

import process from "node:process";
import {
  checkpoint,
  command,
  name,
  option,
  parse,
  printErrors,
  printHelp,
  printVersion,
  type Result,
  schema,
  type ValueSource,
  version,
} from "@frontside/configliere";
import * as z from "zod";

const app = command(
  name("server"),
  version("1.0.0"),
  option(name("config"), schema(z.string())),
  checkpoint(),
  option(name("port"), schema(z.number())),
);

const step = parse(app, { argv: process.argv.slice(2) });

if (!step.ok) {
  console.error(printErrors(step));
  process.exit(1);
}

step.model.config; // string—the model resolved before the checkpoint

const loaded = await load(step.model.config);
const result = step.resume(loaded);

if (!result.ok) {
  console.error(printErrors(result));
  process.exit(1);
}

switch (result.method) {
  case "help":
    console.log(printHelp(result));
    break;

  case "version":
    console.log(printVersion(result));
    break;

  case "execute":
    result.model; // { config: string; port: number }
    break;
}

declare function load(path: string): Promise<Result<ValueSource[]>>;

The parser remains synchronous and performs no I/O. The caller loads the file and resumes with a Result; loader failures enter the ordinary issue path. Unconsumed CLI input survives the pause, so a later --port 5000 can override the value loaded from the file.

The same phase mechanism can add options or routes from runtime data. Parsing then continues against the expanded route graph, and the continuation type describes the entry points that can appear next.