Skip to main content
incur can mount any HTTP API that implements the Web Fetch API as a CLI. This works seamlessly with frameworks like Hono, Bun, Deno, and Elysia.

When to Mount APIs

Mount APIs as CLIs when you:
  • Want to expose HTTP endpoints via CLI without writing separate handlers
  • Need a unified interface for both web and CLI access
  • Have an existing API and want instant CLI support
  • Want to test API endpoints directly from the command line

Basic API Mounting

1
Create an API with Fetch Handler
2
Any framework that exposes a fetch handler works:
3
import { Hono } from 'hono'

const app = new Hono()
  .get('/users', (c) => c.json({ users: [{ id: 1, name: 'Alice' }] }))
  .post('/users', async (c) => c.json({ created: true, ...(await c.req.json()) }, 201))
4
Mount with fetch Property
5
Pass the fetch handler to Cli.create():
6
import { Cli } from 'incur'

Cli.create('my-cli', {
  description: 'My CLI',
  fetch: app.fetch,  // Mount the entire API
}).serve()
7
Use curl-style Syntax
8
Arguments become path segments, flags become options:
9
my-cli api users
# → users[1]{id,name}:
# →   1,Alice

my-cli api users -X POST -d '{"name":"Bob"}'
# → created: true
# → name: Bob

curl-Style Translation

incur translates CLI arguments to HTTP requests using curl-style conventions:

Path Segments

Bare arguments become path segments:
my-cli api users 42
# → GET /users/42

HTTP Methods

Use -X or --method to set the HTTP method:
my-cli api users -X POST
# → POST /users

my-cli api users 42 --method DELETE
# → DELETE /users/42
Default method is GET, or POST if a body is provided via -d or --data.

Request Body

Use -d, --data, or --body to send a request body:
my-cli api users -d '{"name":"Charlie"}'
# → POST /users
# → Body: {"name":"Charlie"}

my-cli api users --data '{"id":1,"name":"Alice"}' -X PUT
# → PUT /users
# → Body: {"id":1,"name":"Alice"}

Headers

Use -H or --header to set headers:
my-cli api users -H "Authorization: Bearer token123"
# → GET /users
# → Header: Authorization: Bearer token123

my-cli api admin -H "X-Api-Key: secret" -H "X-Request-Id: abc"
# → Multiple headers

Query Parameters

Any unknown --flag becomes a query parameter:
my-cli api users --limit 5 --sort name
# → GET /users?limit=5&sort=name

my-cli api search --q hello --page 2
# → GET /search?q=hello&page=2

Framework Examples

Hono

import { Cli } from 'incur'
import { Hono } from 'hono'

const app = new Hono()
  .get('/users', (c) => c.json({ users: [{ id: 1, name: 'Alice' }] }))
  .get('/users/:id', (c) => c.json({ id: Number(c.req.param('id')), name: 'Alice' }))
  .post('/users', async (c) => c.json({ created: true, ...(await c.req.json()) }, 201))

Cli.create('api', {
  description: 'API CLI',
  fetch: app.fetch,
}).serve()
api api users
# → users[1]{id,name}:
# →   1,Alice

api api users 42
# → id: 42
# → name: Alice

api api users -X POST -d '{"name":"Bob"}'
# → created: true
# → name: Bob

Bun

import { Cli } from 'incur'

const bunApp = {
  fetch(req: Request) {
    const url = new URL(req.url)
    if (url.pathname === '/ping') {
      return Response.json({ pong: true })
    }
    return new Response('Not found', { status: 404 })
  },
}

Cli.create('bun-cli', {
  description: 'Bun CLI',
  fetch: bunApp.fetch,
}).serve()
bun-cli api ping
# → pong: true

Deno

import { Cli } from 'incur'

const denoApp = {
  fetch(req: Request) {
    return Response.json({ hello: 'deno' })
  },
}

Cli.create('deno-cli', {
  fetch: denoApp.fetch,
}).serve()

Elysia

import { Cli } from 'incur'
import { Elysia } from 'elysia'

const app = new Elysia()
  .get('/health', () => ({ status: 'ok' }))
  .post('/users', ({ body }) => ({ created: true, ...body }))

Cli.create('elysia-cli', {
  fetch: app.fetch,
}).serve()

Mounting as Commands

Mount APIs on specific commands instead of the root:
import { Cli } from 'incur'
import { Hono } from 'hono'

const app = new Hono()
  .get('/users', (c) => c.json({ users: [{ id: 1, name: 'Alice' }] }))
  .post('/users', async (c) => c.json({ created: true, ...(await c.req.json()) }, 201))

Cli.create('my-cli', { description: 'My CLI' })
  .command('users', { fetch: app.fetch })  // Mount on 'users' command
  .serve()
my-cli users users
# → users[1]{id,name}:
# →   1,Alice

my-cli users users -X POST -d '{"name":"Bob"}'
# → created: true
# → name: Bob

Base Path

Specify a base path to strip from CLI arguments:
Cli.create('my-cli', {})
  .command('api', {
    description: 'API commands',
    fetch: app.fetch,
    basePath: '/v1',  // CLI paths will be prefixed with /v1
  })
  .serve()
my-cli api users
# → GET /v1/users (not /users)

OpenAPI Integration

Pass an OpenAPI spec alongside fetch to generate typed subcommands:
import { Cli } from 'incur'
import { app, spec } from './my-hono-openapi-app.js'

Cli.create('my-cli', { description: 'My CLI' })
  .command('api', {
    fetch: app.fetch,
    openapi: spec,  // Generate commands from spec
  })
  .serve()

Generated Commands

incur extracts:
  • Command names from operationId
  • Descriptions from summary or description
  • Arguments from path parameters
  • Options from query parameters and request body properties
my-cli api --help
# Commands:
#   listUsers    List users
#   createUser   Create a user
#   getUser      Get a user by ID

Typed Subcommands

Each operation becomes a typed command:
my-cli api listUsers --limit 5
# → users: ...

my-cli api getUser 42
# → id: 42
# → name: Alice

my-cli api createUser --name Bob
# → created: true
# → name: Bob
OpenAPI integration uses @readme/openapi-parser to resolve all $ref pointers automatically. The spec can be passed as a plain object or imported JSON.

Example OpenAPI App

import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'

const app = new OpenAPIHono()

app.openapi(
  createRoute({
    method: 'get',
    path: '/users',
    operationId: 'listUsers',
    summary: 'List users',
    request: {
      query: z.object({
        limit: z.coerce.number().default(10).openapi({ description: 'Number of users' }),
      }),
    },
    responses: {
      200: {
        description: 'List of users',
        content: {
          'application/json': {
            schema: z.object({
              users: z.array(z.object({ id: z.number(), name: z.string() })),
            }),
          },
        },
      },
    },
  }),
  (c) => {
    const limit = c.req.query('limit')
    return c.json({ users: [{ id: 1, name: 'Alice' }] })
  },
)

app.openapi(
  createRoute({
    method: 'get',
    path: '/users/{id}',
    operationId: 'getUser',
    summary: 'Get a user by ID',
    request: {
      params: z.object({
        id: z.coerce.number().openapi({ description: 'User ID' }),
      }),
    },
    responses: {
      200: {
        description: 'User details',
        content: {
          'application/json': {
            schema: z.object({ id: z.number(), name: z.string() }),
          },
        },
      },
    },
  }),
  (c) => {
    const id = Number(c.req.param('id'))
    return c.json({ id, name: 'Alice' })
  },
)

export { app }
export const spec = app.getOpenAPIDocument({
  openapi: '3.1.0',
  info: { title: 'My API', version: '1.0.0' },
})

Usage

my-cli api listUsers --limit 5
# → users[1]{id,name}:
# →   1,Alice

my-cli api getUser 42
# → id: 42
# → name: Alice

Fetch Handler Help

Fetch handlers have specialized help text:
my-cli api --help
# my-cli api
#
# Usage: my-cli api <path> [options]
#
# Arguments:
#   path  API path (e.g., users, users/42)
#
# Options:
#   -X, --method <method>   HTTP method (GET, POST, PUT, DELETE, etc.)
#   -d, --data <json>       Request body (implies POST)
#   -H, --header <header>   Request header (format: "Name: value")
#   --<key> <value>         Query parameters
#
# Examples:
#   my-cli api users
#   my-cli api users/42
#   my-cli api users -X POST -d '{"name":"Alice"}'
#   my-cli api users --limit 10 --sort name

Streaming Responses

APIs that return NDJSON streams are automatically handled:
const app = new Hono()
  .get('/logs', (c) => {
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode(JSON.stringify({ line: 'Log 1' }) + '\n'))
        controller.enqueue(new TextEncoder().encode(JSON.stringify({ line: 'Log 2' }) + '\n'))
        controller.close()
      },
    })
    return new Response(stream, {
      headers: { 'content-type': 'application/x-ndjson' },
    })
  })

Cli.create('logs', { fetch: app.fetch }).serve()
logs api logs
# → line: Log 1
# → line: Log 2
Streaming only works for responses with content-type: application/x-ndjson. Each line is parsed as JSON and output incrementally.

Error Handling

HTTP error responses are automatically converted to CLI errors:
const app = new Hono()
  .get('/users/:id', (c) => {
    const id = Number(c.req.param('id'))
    if (id > 100) {
      return c.json({ message: 'User not found' }, 404)
    }
    return c.json({ id, name: 'Alice' })
  })

Cli.create('api', { fetch: app.fetch }).serve()
api api users 999
# Error (HTTP_404): User not found

Best Practices

Use OpenAPI for Complex APIs

For APIs with many endpoints, use OpenAPI to get typed commands automatically:
// Good: typed commands from spec
Cli.create('api', { fetch: app.fetch, openapi: spec })

// Avoid: manual curl-style for complex APIs
Cli.create('api', { fetch: app.fetch })

Mount on Subcommands

Keep the root clean by mounting APIs on subcommands:
// Good: clear namespace
Cli.create('my-cli', {})
  .command('api', { fetch: app.fetch })
  .command('db', { fetch: dbApp.fetch })

// Avoid: API at root
Cli.create('my-cli', { fetch: app.fetch })

Provide Base Paths

Use basePath to simplify CLI arguments:
// Good: clean CLI paths
Cli.create('api', { fetch: app.fetch, basePath: '/api/v1' })

// Avoid: users type full paths
Cli.create('api', { fetch: app.fetch })
Mounted APIs receive all remaining CLI arguments as path segments and flags. Don’t mix fetch handlers with manual command definitions in the same CLI level.

Build docs developers (and LLMs) love