DEV Community

kiyo-e
kiyo-e

Posted on • Originally published at kiyo-e.github.io

Turn a Hono App into a Debuggable CLI (No Server, Just app.fetch)

If you've ever built a CLI and hated the edit-run-repeat loop, this pattern helps:

  • Put all business logic in a Hono app
  • Call it from the CLI via app.fetch() (no HTTP server)
  • Keep the adapter pure: no stdout/stderr writes, your CLI owns the output

This post shows a minimal setup, how argv maps to URL/query/body, and optional OpenAPI-powered --help.

TL;DR

  • hono-cli-adapter lets you call Hono apps directly from the CLI
  • Your logic stays in Hono—debug with Postman, ship as CLI
  • Zero stdout writes; your CLI controls all output
  • Same Hono app works for CLI, HTTP, and MCP servers

The Problem

Debugging CLI tools is tedious. Run, tweak args, run again. No request history, no easy inspection.

What if your CLI logic lived behind HTTP endpoints instead? You'd get Postman for debugging, saved requests for regression tests, and a single source of truth for both CLI and API.

What I Built

hono-cli-adapter — a thin library that converts CLI arguments into HTTP requests and calls your Hono app's app.fetch() directly.

No actual HTTP server needed. Just your Hono app and a few lines of CLI glue.

Getting Started

Install:

npm install hono-cli-adapter
Enter fullscreen mode Exit fullscreen mode

First, your Hono app (this is the logic you want to call from CLI):

// app.ts
import { Hono } from 'hono'
export const app = new Hono()

app.post('/hello/:name', (c) => c.text(`Hello, ${c.req.param('name')}!`))
app.post('/create-user', async (c) => {
  const body = await c.req.json()
  return c.json({ ok: true, user: body })
})
Enter fullscreen mode Exit fullscreen mode

Then, your CLI (just 4 lines):

#!/usr/bin/env node
// cli.ts
import { cli } from 'hono-cli-adapter'
import { app } from './app.js'

await cli(app)
Enter fullscreen mode Exit fullscreen mode

Run it:

node cli.js hello Taro
# -> Hello, Taro!

node cli.js create-user -- name=Taro email=taro@example.com
# -> {"ok":true,"user":{"name":"Taro","email":"taro@example.com"}}

node cli.js --list   # List available routes
node cli.js --help   # Show help
Enter fullscreen mode Exit fullscreen mode

That's it. The same app.ts works with Postman during dev, as an HTTP API in production, and now as a CLI.

How argv Maps to HTTP

CLI input Becomes
hello Taro Path segments (POST /hello/Taro)
--foo=bar Query string (?foo=bar)
-- key=value JSON body ({"key":"value"})
--env KEY=VALUE Env overlay (highest priority)

How It Works

Three design constraints:

1. Thin CLI, fat Hono

All business logic lives in Hono. The CLI just handles flags and output. This keeps behavior consistent between CLI and HTTP, and makes your Hono app fully testable on its own.

2. No side effects

The library never touches stdout. You decide how to format output:

const { code, lines } = await runCli(app, process)
for (const l of lines) console.log(l)  // or JSON.stringify, or pipe somewhere
process.exit(code)
Enter fullscreen mode Exit fullscreen mode

3. POST-only

CLI commands trigger actions. POST makes sense. GET support can come later if needed.

MCP Server Support

Here's where Hono really shines. The same app works as:

┌─────────────┐
│   app.ts    │  ← Your business logic (single source of truth)
└─────────────┘
       │
       ├──→ cli.ts (hono-cli-adapter) → CLI
       ├──→ server.ts (Hono serve)    → HTTP API
       └──→ mcp.ts (mcp-hono-adapter) → MCP Server
Enter fullscreen mode Exit fullscreen mode

Just swap the entrypoint. No logic duplication. If you're building MCP tools, this pattern saves a ton of maintenance.

Advanced Usage

Environment Variables

Three layers, last wins:

// 1. process.env (base)
// 2. options.env (adapter config)
// 3. --env flags (highest priority)

await cli(app, process, { env: { API_URL: 'https://dev.example.com' } })
Enter fullscreen mode Exit fullscreen mode
node cli.js do-thing --env API_KEY=secret-123
Enter fullscreen mode Exit fullscreen mode

beforeFetch Hook

Transform requests per command:

await adaptAndFetch(app, process.argv.slice(2), {
  beforeFetch: {
    upload: async (req, argv) => {
      if (argv.file) {
        const buf = await fs.readFile(argv.file)
        const headers = new Headers(req.headers)
        headers.set('content-type', 'application/octet-stream')
        return new Request(req, { body: buf, headers })
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

OpenAPI Integration

Pass a spec to enrich --help:

await runCli(app, process, { openapi: myOpenApiSpec })
Enter fullscreen mode Exit fullscreen mode

Shows parameter types, required/optional, descriptions. Pairs well with hono-openapi.

Gotchas

listPostRoutes uses Hono internals

It inspects Hono's internal router structure. May break on major Hono updates. For production, consider maintaining your own route list.

ESM only

No CommonJS. Node 18+ required.

Wrapping Up

Hono + CLI is a pattern that deserves more attention. You get web tooling during dev, trivial MCP support, and a testable core—all without duplicating logic.

Check it out: github.com/kiyo-e/hono-cli-adapter

Top comments (0)