Middleware

Middleware lets you transform, validate, or enrich arguments before the command handler runs. Instead of cluttering your handler with setup logic, middleware keeps handlers focused on their core behavior while making cross-cutting concerns composable and reusable.

Basic middleware

Register middleware with .middleware(). Each middleware receives the current args and can modify them or return new properties:

const cli = cliForge('basic-cli')
  // Requires a command to be provided
  .demandCommand()

  // Middleware registered on parent commands will be invoked before the child command's middleware
  .middleware(() => {
    console.log('ABOUT TO RUN A COMMAND');
  })

  // Registers "hello" command
  .command('hello', {
    // Builder is used to define the command's options
    builder: (args) =>
      args
        .option('name', {
          type: 'string',
          description: 'The name to say hello to',
          default: 'World',
        })
        // Middleware can mutate the args object
        .middleware((args) => {
          args.name = args.name.toUpperCase();
        })
        // Middleware can add new properties to the args object
        .middleware((args) => {
          return {
            ...args,
            env: process.env.NODE_ENV || 'development',
          };
        })
        // Multiple middleware can be registered
        .middleware(() => {
          console.log('HELLO MIDDLEWARE');
        }),
    // Handler is used to define the command's behavior
    handler: (args) => {
      console.log(`Hello, ${args.name}! [${args.env.toUpperCase()}]`);
    },
  });

Middleware registered on a parent command runs before the child command's middleware. This makes it ideal for logging, authentication checks, or any setup that applies across multiple commands.

What middleware can do

Middleware functions receive the accumulated args object and can:

  • Mutate args in place — e.g., normalizing a string to uppercase
  • Return new properties — spread the existing args and add fields; TypeScript tracks the new type
  • Perform side effects — log, check permissions, start timers
  • Run async work — middleware can be async and will be awaited

Composing middleware from modules

For larger CLIs, define middleware in separate files with explicit type signatures. This keeps each concern isolated and testable.

Start by defining the types each middleware contributes:

/**
 * Represents an authenticated user in the system.
 */
export interface User {
  id: string;
  name: string;
  role: 'admin' | 'user';
}

/**
 * Context added by the auth middleware.
 */
export interface AuthContext {
  user: User;
  authenticated: boolean;
}

/**
 * Context added by the timing middleware.
 */
export interface TimingContext {
  startTime: number;
  requestId: string;
}

Then implement each middleware in its own module. Here's a timing middleware that adds a request ID and start time:

/**
 * Timing middleware that adds request timing context.
 *
 * The startTime can be used by handlers to calculate execution duration.
 * The requestId provides a correlation ID for logging.
 */
export function timingMiddleware<T>(args: T): T & TimingContext {
  return {
    ...args,
    startTime: Date.now(),
    requestId: generateRequestId(),
  };
}

And an authentication middleware that adds user context:

/**
 * Authentication middleware that adds user context to the args.
 *
 * This middleware demonstrates how to add typed properties that
 * downstream middleware and handlers can rely on.
 */
export function authMiddleware<T>(args: T): T & AuthContext {
  const user = lookupUser();

  return {
    ...args,
    user,
    authenticated: true,
  };
}

Finally, compose them in your command definition:

const app = cli('middleware-demo')
  .command('greet', {
    builder: (cmd) =>
      cmd
        .option('name', {
          type: 'string',
          description: 'Name to greet',
          required: true,
        })
        // Middleware is applied in order. Each one adds to the args type.
        .middleware(timingMiddleware)
        .middleware(authMiddleware),

    handler: (args) => {
      // TypeScript knows args has: name, startTime, requestId, user, authenticated
      console.log(`[${args.requestId}] Hello, ${args.name}!`);
      console.log(`  Authenticated as: ${args.user.name} (${args.user.role})`);
      console.log(`  authenticated: ${args.authenticated}`);
      console.log(`  Request completed in ${getElapsedMs(args.startTime)}ms`);
    },
  });

Each .middleware() call adds its return type to the args. By the time the handler runs, TypeScript knows args has name, startTime, requestId, user, and authenticated — all fully typed.

Execution order and deduplication

Middleware follows two rules:

  1. Parent-first ordering — middleware on a parent command runs before middleware on child commands.
  2. Run-once deduplication — each middleware function runs exactly once per invocation, even if the same function is registered at multiple levels. CLI Forge tracks this by reference, so reusing the same function across commands is safe.

When to use middleware vs. handler logic

Use middleware whenUse handler logic when
The logic applies to multiple commandsThe logic is specific to one command
You need to add typed properties to argsYou are consuming args, not transforming them
You want to keep the handler focusedThe setup is minimal (one line)

For the full middleware composition example with test assertions, see the middleware composition example.