Skip to main content
Register composable before/after hooks with cli.use(). Middleware executes in registration order, onion-style—each calls await next() to proceed to the next middleware or the command handler.

Basic Middleware

Use middleware for cross-cutting concerns like timing, logging, or auth:
import { Cli } from 'incur'

const cli = Cli.create('deploy-cli', { description: 'Deploy tools' })
  .use(async (c, next) => {
    const start = Date.now()
    await next()
    console.log(`took ${Date.now() - start}ms`)
  })
  .command('deploy', {
    run() {
      return { deployed: true }
    },
  })

cli.serve()
Output
$ deploy-cli deploy
deployed: true
took 12ms

Middleware Context

Middleware receives a context object with metadata:
import { Cli } from 'incur'

Cli.create('my-cli', { description: 'My CLI' })
  .use(async (c, next) => {
    console.log(`Running: ${c.command}`)
    console.log(`CLI: ${c.name}`)
    console.log(`Agent: ${c.agent}`)
    await next()
  })
  .command('deploy', {
    run() {
      return { deployed: true }
    },
  })
  .serve()
Output
$ my-cli deploy
Running: deploy
CLI: my-cli
Agent: false
deployed: true

Context Properties

PropertyTypeDescription
c.namestringThe CLI name
c.commandstringThe resolved command path
c.versionstring | undefinedCLI version
c.agentbooleanWhether stdout is not a TTY
c.envobjectParsed environment variables
c.varobjectVariables set by upstream middleware
c.set(key, value)functionSet a typed variable
c.error(opts)functionReturn an error result

Authentication Middleware

Short-circuit the chain by returning early:
import { Cli, middleware, z } from 'incur'

type User = { id: string; name: string }

const cli = Cli.create('my-cli', {
  description: 'My CLI',
  vars: z.object({ user: z.custom<User>() }),
})

const requireAuth = middleware<typeof cli.vars>((c, next) => {
  if (!c.var.user) {
    return c.error({ 
      code: 'AUTH', 
      message: 'must be logged in' 
    })
  }
  return next()
})

cli.command('deploy', {
  middleware: [requireAuth],
  run() {
    return { deployed: true }
  },
})

cli.serve()
Output
$ my-cli deploy
Error (AUTH): must be logged in

Dependency Injection with Variables

Declare a vars schema to enable typed variables. Middleware sets them with c.set(), and both middleware and command handlers read them via c.var:
import { Cli, z } from 'incur'

type User = { id: string; name: string }

async function authenticate(): Promise<User> {
  return { id: 'u_123', name: 'Alice' }
}

const cli = Cli.create('my-cli', {
  description: 'My CLI',
  vars: z.object({
    user: z.custom<User>(),
    requestId: z.string(),
    debug: z.boolean().default(true),
  }),
})

cli.use(async (c, next) => {
  c.set('user', await authenticate())
  c.set('requestId', crypto.randomUUID())
  await next()
})

cli.command('whoami', {
  run(c) {
    return { 
      user: c.var.user, 
      requestId: c.var.requestId, 
      debug: c.var.debug 
    }
  },
})

cli.serve()
Output
$ my-cli whoami
user:
  id: u_123
  name: Alice
requestId: 550e8400-e29b-41d4-a716-446655440000
debug: true

Default Values

Use .default() for variables that don’t need middleware:
const cli = Cli.create('my-cli', {
  vars: z.object({
    debug: z.boolean().default(true),
    user: z.custom<User>(),
  }),
})

cli.command('test', {
  run(c) {
    // c.var.debug is always available
    // c.var.user must be set by middleware
    return { debug: c.var.debug }
  },
})

Per-Command Middleware

Run middleware only for specific commands:
import { Cli, middleware, z } from 'incur'

type User = { id: string; name: string; admin: boolean }

const cli = Cli.create('my-cli', {
  description: 'My CLI',
  vars: z.object({ user: z.custom<User>() }),
})

const requireAuth = middleware<typeof cli.vars>((c, next) => {
  if (!c.var.user) {
    return c.error({ code: 'AUTH', message: 'must be logged in' })
  }
  return next()
})

const requireAdmin = middleware<typeof cli.vars>((c, next) => {
  if (!c.var.user?.admin) {
    throw new Error('admin required')
  }
  return next()
})

cli
  .command('deploy', {
    middleware: [requireAuth],
    run() {
      return { deployed: true }
    },
  })
  .command('delete', {
    middleware: [requireAuth, requireAdmin],
    run() {
      return { deleted: true }
    },
  })
  .command('status', {
    // No auth required
    run() {
      return { status: 'ok' }
    },
  })

cli.serve()
Output
$ my-cli deploy
Error (AUTH): must be logged in

$ my-cli status
status: ok
Per-command middleware runs after root and group middleware.

Error Handling

Middleware can handle errors from downstream middleware or commands:
import { Cli } from 'incur'

Cli.create('my-cli', { description: 'My CLI' })
  .use(async (c, next) => {
    try {
      await next()
    } catch (error) {
      console.error('Caught error:', error)
      throw error // Re-throw or handle
    }
  })
  .command('fail', {
    run() {
      throw new Error('Something went wrong')
    },
  })
  .serve()

Structured Errors

Return structured errors with error codes:
import { Cli, middleware, z } from 'incur'

const requireAuth = middleware((c, next) => {
  if (!authenticated()) {
    return c.error({
      code: 'AUTH',
      message: 'must be logged in',
      retryable: false,
    })
  }
  return next()
})
Output
$ my-cli deploy
Error (AUTH): must be logged in
Structured errors show up in the output envelope with code and retryable flag.

Throwing Errors

Throwing also works and produces an UNKNOWN error code:
const requireAdmin = middleware<typeof cli.vars>((c, next) => {
  if (!c.var.user?.admin) throw new Error('admin required')
  return next()
})

Typed Middleware

Use the middleware() helper for full type safety:
import { middleware, z } from 'incur'

type User = { id: string; name: string }

const vars = z.object({
  user: z.custom<User>(),
  requestId: z.string(),
})

const env = z.object({
  API_KEY: z.string(),
})

// Pass vars and env schemas as generics
const myMiddleware = middleware<typeof vars, typeof env>((c, next) => {
  // c.var is typed as { user: User, requestId: string }
  // c.set() is typed to accept only valid keys
  // c.env is typed as { API_KEY: string }
  
  c.set('requestId', crypto.randomUUID())
  return next()
})

Environment Variables

Access parsed environment variables in middleware:
import { Cli, middleware, z } from 'incur'

const cli = Cli.create('my-cli', {
  description: 'My CLI',
  env: z.object({
    API_KEY: z.string(),
    DEBUG: z.boolean().default(false),
  }),
})

const logEnv = middleware<typeof cli.vars, typeof cli.env>((c, next) => {
  console.log('API_KEY:', c.env.API_KEY)
  console.log('DEBUG:', c.env.DEBUG)
  return next()
})

cli.use(logEnv)
Environment variables are validated once at startup before any middleware runs.

Middleware Order

Middleware executes in an onion pattern:
import { Cli } from 'incur'

Cli.create('my-cli', { description: 'My CLI' })
  .use(async (c, next) => {
    console.log('1: before')
    await next()
    console.log('1: after')
  })
  .use(async (c, next) => {
    console.log('2: before')
    await next()
    console.log('2: after')
  })
  .command('test', {
    run() {
      console.log('command')
      return { ok: true }
    },
  })
  .serve()
Output
$ my-cli test
1: before
2: before
command
2: after
1: after
ok: true

Execution Order

  1. Root CLI middleware (in registration order)
  2. Group CLI middleware (if mounted)
  3. Per-command middleware (if defined)
  4. Command run handler

Source Code

The middleware implementation is in src/middleware.ts:
export type Handler<
  vars extends z.ZodObject<any> | undefined = undefined,
  env extends z.ZodObject<any> | undefined = undefined,
> = (context: Context<vars, env>, next: () => Promise<void>) => Promise<void> | void

export default function middleware<
  const vars extends z.ZodObject<any> | undefined = undefined,
  const env extends z.ZodObject<any> | undefined = undefined,
>(handler: Handler<vars, env>): Handler<vars, env> {
  return handler
}
Use middleware for cross-cutting concerns like auth, logging, and timing. Use variables (c.var and c.set()) for type-safe dependency injection.

Build docs developers (and LLMs) love