Build a CLI

Define commands, middleware, configuration, and use maltty's sub-exports to build a complete CLI tool.

Prerequisites

  • Node.js 24+
  • Bun 1.3+ (required if compiling to standalone binaries)
  • maltty installed (pnpm add maltty)

Declare runtime constraints in your CLI's package.json so consumers get clear errors on incompatible runtimes:

{
  "engines": {
    "node": ">=24",
    "bun": ">=1.3"
  }
}

Steps

1. Define a command

Commands accept a description, typed arguments via Zod, and a handler function.

import { command } from 'maltty'
import { z } from 'zod'

const deploy = command({
  description: 'Deploy the application',
  options: z.object({
    env: z.enum(['staging', 'production']).describe('Target environment'),
    dryRun: z.boolean().default(false).describe('Preview without applying'),
  }),
  async handler(ctx) {
    ctx.log.info(`Deploying to ${ctx.args.env}`)
  },
})

2. Bootstrap the CLI

cli() registers commands, parses arguments, runs middleware, and invokes the matched handler.

import { cli } from 'maltty'

cli({
  name: 'my-app',
  version: '1.0.0',
  description: 'My CLI tool',
  commands: { deploy, migrate },
  middleware: [timing],
  help: { header: 'my-app - deploy and migrate with ease' },
})

3. Add middleware

Middleware wraps command execution with pre/post logic. It receives the context and a next function.

Root middleware runs for every command:

import { middleware } from 'maltty'

const timing = middleware(async (ctx, next) => {
  const start = Date.now()
  await next()
  ctx.log.info(`Completed in ${Date.now() - start}ms`)
})

cli({
  name: 'my-app',
  version: '1.0.0',
  middleware: [timing],
  commands: { deploy },
})

Command middleware runs only for a specific command:

const requireAuth = middleware(async (ctx, next) => {
  if (!process.env['API_TOKEN']) {
    ctx.fail('Missing API_TOKEN')
  }
  await next()
})

const deploy = command({
  description: 'Deploy the application',
  middleware: [requireAuth],
  async handler(ctx) {
    ctx.log.raw('Deploying')
  },
})

Root middleware wraps command middleware, which wraps the handler. See Lifecycle for the full execution model.

4. Hide or deprecate commands

Commands support hidden and deprecated fields for controlling help output visibility. Both accept a static value or a function (Resolvable<T>), resolved once at registration time.

// Hidden from --help but still callable
const debug = command({
  description: 'Internal debugging tools',
  hidden: true,
  handler: async (ctx) => {
    /* ... */
  },
})

// Conditionally hidden based on environment
const experimental = command({
  description: 'Experimental feature',
  hidden: () => process.env['NODE_ENV'] === 'production',
  handler: async (ctx) => {
    /* ... */
  },
})

// Deprecated command with message
const legacyDeploy = command({
  description: 'Deploy (legacy)',
  deprecated: 'Use "deploy-v2" instead',
  handler: async (ctx) => {
    /* ... */
  },
})

Individual flags also support hidden, deprecated, and group for organizing help output:

const build = command({
  description: 'Build the project',
  options: {
    trace: { type: 'boolean', description: 'Enable tracing', hidden: true },
    output: { type: 'string', description: 'Output dir', group: 'Build Options:' },
    legacy: { type: 'boolean', description: 'Legacy mode', deprecated: 'Use --modern' },
  },
  handler: async (ctx) => {
    /* ... */
  },
})

5. Add subcommands

Commands can contain nested subcommands:

const generate = command({
  description: 'Code generation utilities',
  commands: {
    types: generateTypes,
    schema: generateSchema,
  },
})

6. Add a default command

Mark a command default: true so it runs when no subcommand is given. Both invocation forms stay available:

const search = command({
  name: 'search',
  default: true,
  description: 'Search things',
  positionals: z.object({ pattern: z.string().describe('Pattern to match').optional() }),
  handler: async (ctx) => {
    ctx.log.info(`Searching for ${ctx.args.pattern}`)
  },
})
my-app needle           # runs search with pattern="needle"
my-app search needle    # same thing
my-app generate         # runs the generate command, not search

A registered command name always wins over the default command's positionals -- my-app generate can never pass generate as a pattern. Use the explicit form when you need that value.

Mark only one command per level as default. Note that adding a default command means a bare my-app runs that handler instead of printing help, so help.header no longer appears.

7. Autoload commands from a directory

Dynamically discover commands at runtime:

import { autoload } from 'maltty'

cli({
  name: 'my-app',
  version: '1.0.0',
  commands: autoload({ dir: './commands' }),
})

Assign it to a key instead -- commands: { generate: autoload({ dir: './commands/generate' }) } -- to mount the directory under a parent command. You then invoke each named command as my-app generate <name>, and a nameless index.ts default as my-app generate.

An index file at the root of an autoloaded directory becomes that level's default command:

commands/
├── index.ts     # default -- runs on `my-app needle`
├── generate.ts  # my-app generate
└── status.ts    # my-app status

Give it a name to keep a named invocation form alongside the default; without one you can invoke it only as the default.

8. Add typed config

Scaffold config setup with the CLI, or create the files manually.

Scaffold with the CLI:

maltty add config

This creates src/config.ts with a Zod schema and ConfigType module augmentation. You can also include config setup when creating a new project:

maltty init --config

Manual setup:

Create a config schema file with ConfigType to derive ConfigRegistry from your Zod schema:

// src/config.ts
import type { ConfigType } from 'maltty/config'
import { z } from 'zod'

export const configSchema = z.object({
  apiUrl: z.string().url(),
  region: z.string().default('us-east-1'),
})

declare module 'maltty/config' {
  interface ConfigRegistry extends ConfigType<typeof configSchema> {}
}

Register the config middleware in cli():

import { cli } from 'maltty'
import { config } from 'maltty/config'
import { configSchema } from './config.js'

cli({
  name: 'my-app',
  version: '1.0.0',
  middleware: [config({ schema: configSchema })],
  commands: import.meta.dirname + '/commands',
})

Commands load config lazily via the ctx.config handle:

export default command({
  async handler(ctx) {
    const [error, result] = await ctx.config.load()
    if (error) {
      ctx.fail(error.message)
      return
    }
    result.config.apiUrl // string
    result.config.region // string
  },
})

To load config eagerly (during middleware pass), use eager: true:

config({ schema: configSchema, eager: true })

Standalone config client:

For loading config outside the cli() bootstrap, use createConfigClient:

import { createConfigClient } from 'maltty/config'

const config = createConfigClient({ name: 'my-app', schema: MySchema })
const [error, result] = await config.load()

9. Use sub-exports

maltty exposes focused utilities through sub-exports.

Log -- structured terminal output is available on every context:

// In any command handler or middleware:
ctx.log.intro('My CLI')
ctx.log.info('Processing...')
ctx.log.success('Complete')
ctx.log.outro('Done')

Store -- file-backed JSON store for persistent data (separate from the in-memory ctx.store used for middleware-to-handler data flow):

import { createStore } from 'maltty/store'

const store = createStore({ dirName: '.my-app' })
const settings = store.load('settings.json')

Project -- git root resolution, submodule detection, path utilities:

import { findProjectRoot, isInSubmodule, resolvePath } from 'maltty/project'

const root = findProjectRoot()
const inSubmodule = isInSubmodule()
const appDir = resolvePath({ dirName: '.my-app' })

findProjectRoot returns ProjectRoot | null (with path and isSubmodule properties). resolvePath accepts { dirName, source?, startDir? } and resolves to a local or global directory path.

10. Define a screen command

For interactive terminal UIs, use screen() instead of command(). Screen commands render a React component using Ink.

import { Box, screen, Text, useApp } from 'maltty/ui'
import React from 'react'
import { z } from 'zod'

function Greeting({ name }: { readonly name: string }): React.ReactElement {
  const { exit } = useApp()

  React.useEffect(() => {
    const timer = setTimeout(() => exit(), 2000)
    return () => {
      clearTimeout(timer)
    }
  }, [exit])

  return (
    <Box padding={1}>
      <Text color="green" bold>
        Hello, {name}!
      </Text>
    </Box>
  )
}

export default screen({
  description: 'Greet with a styled UI',
  options: z.object({
    name: z.string().describe('Name to greet'),
  }),
  render: Greeting,
})

Screen commands use .tsx files and access runtime context via hooks (useConfig(), useMeta(), useStore()) instead of the ctx object. See Screens for the full guide.

Verification

npx my-app --help
npx my-app deploy --env staging --dry-run

Troubleshooting

Autoload finds no commands

Issue: autoload() returns an empty command tree.

Fix: Ensure the dir path is relative to the compiled entrypoint, not the source file. Check that each file in the directory exports a command() as its default export.

Config file not found

Issue: config.load() returns a parse error.

Fix: Confirm the config file is named maltty.config.ts (or .js, .json, .yaml) and is in the project root.

Resources

References