Tiny library to adapt CLI arguments into a POST request for a Hono app — without touching stdout. It lets you build a small custom CLI around an existing Hono application, while keeping this package a pure, testable library.
Status: minimal but usable. ESM-only.
- Keep your CLI thin: printing, JSON formatting, and flags live in your own small bin.
- Library has no side effects: never writes to stdout/stderr.
- Strong constraints: POST-only philosophy for predictable calls from the shell.
- Reserved flags: easily exclude your CLI-only flags from HTTP query strings.
- Default env:
process.envis merged by default. - Env merging: combine
process.env+options.env+ repeated--env KEY=VALUEflags (later wins). - Body tokens: pass
-- key=valuepairs to send JSON payloads.
npm install hono-cli-adapter
Peer/runtime expectations:
- Node 18+ (global WHATWG
fetch) - Hono app instance with
app.fetch(req, env)available
Create a tiny CLI that wraps your existing Hono app. Your CLI controls output and flags; the adapter only builds the request and calls app.fetch.
Default (recommended): one‑liner bin that prints and exits for you.
#!/usr/bin/env node
// my-cli.ts (ESM)
import { cli } from 'hono-cli-adapter'
import { app } from './dist/app.js' // your Hono app export
// Pass a process-like object (for argv/stdout/exit) or omit.
await cli(app) // or simply: await cli(app)Pure/no‑stdout variant when you want to fully control printing and exiting yourself:
#!/usr/bin/env node
import { runCli } from 'hono-cli-adapter'
import { app } from './dist/app.js'
const { code, lines } = await runCli(app, process) // or runCli(app)
for (const l of lines) console.log(l)
process.exit(code)Exported from src/index.ts:
type BeforeFetchFn = (req: Request, argv: minimist.ParsedArgs) => Promise<Request | void> | Request | void
type OpenApiSpec = Record<string, unknown>
type OpenApiProvider = OpenApiSpec | (() => OpenApiSpec | Promise<OpenApiSpec>)
type AdapterOptions = {
base?: string
env?: Record<string, unknown>
openapi?: OpenApiProvider
reservedKeys?: string[]
beforeFetch?: BeforeFetchFn | Record<string, BeforeFetchFn>
}
// High-level (recommended for CLI bins)
type RunCliResult = { code: number; lines: string[]; req?: Request; res?: Response }
type ProcLike = { argv?: any[]; stdout?: { write?: (s: string) => unknown }; exit?: (code?: number) => unknown }
function cli(app: any, argvOrProcess?: string[] | ProcLike | AdapterOptions, options?: AdapterOptions): Promise<number>
function runCli(app: any, argvOrProcess?: string[] | ProcLike | AdapterOptions, options?: AdapterOptions): Promise<RunCliResult>
// Mid-level
function adaptAndFetch(
app: any,
argvRaw?: string[] /* defaults to process.argv.slice(2) */,
options?: AdapterOptions
): Promise<{ req: Request; res: Response }>
// Request building
function buildUrlFromArgv(argv: minimist.ParsedArgs, options?: AdapterOptions): URL
function buildRequestFromArgv(argv: minimist.ParsedArgs, options?: AdapterOptions): Request
function parseEnvFlags(envFlags: string | string[] | undefined): Record<string, string>
function parseBodyTokens(tokens: string | string[] | undefined): Record<string, string>
function commandFromArgv(argv: minimist.ParsedArgs, options?: AdapterOptions): string | undefined
// Route helpers
function listPostRoutes(app: any): string[]
function routePathToCommandSegments(routePath: string): string[]
function buildCommandExample(routePath: string, cmdBase: string): string
function buildCommandExamples(routes: string[], cmdBase: string): string[]
function detectCommandBase(argv0?: string, argv1?: string): string
function listRoutesWithExamples(app: any, cmdBase?: string): { routes: string[]; examples: string[] }
function listCommandExamples(app: any, cmdBase?: string): string[]
function listHelpLinesFromOpenApi(openapi: any, cmdBase?: string): string[]Key behaviors:
- Reserved keys default to
['_', '--', 'base', 'env']and are removed from the query string. Add your own viaoptions.reservedKeys. runCli/clialso reserve global flags automatically:json,list,help,base,env.- If
options.openapiis provided,--helpuses it to include per-parameter details (type, required/optional, description, default) and--listincludes--paramexamples from the spec. - Environment precedence when calling
app.fetch(req, env):process.env(base, included by default)options.env(overrides base)--env KEY=VALUEflags (highest precedence)
- Tokens after
--likekey=valuebecome a JSON body. buildRequestFromArgvandadaptAndFetchuse POST only.listPostRoutesis best-effort and relies on Hono’s internal shape. It enumerates POST paths only.
Two ways to integrate (besides the default cli above):
- High-level: let the adapter parse
argvRawfor the common flags--base,--env.
const { res } = await adaptAndFetch(app, process.argv.slice(2), { reservedKeys: ['json'] })- Lower-level: parse your own flags, then build the request yourself.
import minimist from 'minimist'
import { buildRequestFromArgv, parseEnvFlags, listRoutesWithExamples, detectCommandBase } from 'hono-cli-adapter'
const argv = minimist(process.argv.slice(2), { string: ['env'], '--': true })
const req = buildRequestFromArgv(argv, { base: '/v1', reservedKeys: ['json'] })
const res = await app.fetch(req, { ...parseEnvFlags(argv.env) })
// Show routes plus runnable examples (you print; library does not):
const { routes, examples } = listRoutesWithExamples(app, detectCommandBase())
console.log('POST routes:')
for (const p of routes) console.log(' POST ' + p)
console.log('\nCommand examples:')
for (const ex of examples) console.log(' ' + ex)If you have an OpenAPI spec (e.g., from hono-openapi + describeRoute), pass it to runCli to enrich --help:
const openapi = await getOpenApiSpecSomehow()
const { lines } = await runCli(app, process, { openapi })
for (const l of lines) console.log(l)Tweak the outgoing Request before sending:
import fs from 'node:fs/promises'
import minimist from 'minimist'
import { adaptAndFetch, commandFromArgv } from 'hono-cli-adapter'
const argv = minimist(process.argv.slice(2), { '--': true })
const cmd = commandFromArgv(argv) // safe: no direct argv._ access
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 })
}
}
}
})This repo ships runnable examples:
example/basic/— minimal Hono app + CLI (example/basic/README.md)example/slaq/— Slack-focused CLI example built on this adapter
Quick taste:
npm run build
node example/basic/cli.mjs --list # prints runnable command examples only
node example/basic/cli.mjs --help # adds a Flags section
node example/basic/cli.mjs hello Taro # -> "Hello, Taro!"
node example/basic/cli.mjs env API_KEY --env API_KEY=secret-123 # -> "API_KEY=secret-123"
Single-file binary via Bun:
npm run build:example:bin
./example/basic/bin/hono-example --list
./example/basic/bin/hono-example --help # shows flags
- POST-only now; adding other methods can be an additive future feature when/if needed.
- The adapter never writes to stdout — your CLI formats results (plain text, JSON, etc.).
runCliautomatically excludes the global CLI flags from the query. Add more viareservedKeyswhen needed.
- ESM only. If you need CJS, transpile on your side.
- Node 18+ recommended. If you don’t have global
fetch, polyfill (e.g.undici).
listPostRoutesdepends on Hono internals and may break if they change. Consider maintaining your own list if you need strong guarantees.
npm i
npm run build
That’s it — minimal surface, focused on the adapter behavior.
MIT © K.Endo