Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ hono request <path> [file] [options]
- `--runtime <runtime>` - runtime to execute the app: `node` (default), `bun`, `deno`, or `workerd`
- `-i, --include` - Include status and headers in the output (with `--plain`)
- `-I, --head` - Show only status and headers in the output (with `--plain`)
- `--compact` - One-line JSON without the headers
- `-e, --external <package>` - Mark package as external (can be used multiple times)

**Examples:**
Expand Down Expand Up @@ -244,6 +245,7 @@ hono batch <source> [file]
**Options:**

- `-H, --header <header>` - Shared headers for every step
- `--compact` - Print only the failed steps and the summary, as one-line JSON
- `-e, --external <package>` - Mark package as external (can be used multiple times)

```bash
Expand All @@ -265,7 +267,7 @@ Print the current behavior of the app as batch JSONL lines, to stdout — no fil
hono snapshot [file]
```

Paramless GET routes are executed and their actual response becomes the `expect`. Param and non-GET routes are printed without one, to fill in. One probe line records the current response for a path that matches no route. Capture before a refactor, then rerun the lines with `hono batch` until `failed` is 0.
Paramless GET routes are executed and their actual response becomes the `expect` (`--status-only` captures only the status codes — much smaller on a large app; the probe line keeps its body either way). Param and non-GET routes are printed without one, to fill in. One probe line records the current response for a path that matches no route. Capture before a refactor, then rerun the lines with `hono batch` until `failed` is 0.

Unlike `routes`, this command sends real requests to the app — middleware runs. `routes` never sends a request.

Expand Down
19 changes: 19 additions & 0 deletions docs/agent-dx-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
How measurements from [honojs/agent-dx](https://github.com/honojs/agent-dx)
changed Hono CLI. Newest first.

## 2026-09-07: The loop works — now make it cheap

**Experiment**: `next.5` re-measurements. The spec-in-the-request
condition: 3/3 at a 79k median (file-parity, beats self-authoring).
The snapshot workflow on a 27-route split refactor: baseline 3/5 →
5/5 with one AGENTS.md line, snapshot used in every run — but 0/5
from the skill alone (a refactor request does not fire it, again).

**Findings**: the verification loop wins on correctness and loses on
tokens: the snapshot run cost 262k vs a 149k baseline, mostly full
bodies printed for 27 routes, echoed back through the batch results.

**Changes**: cost controls, all opt-in flags. `hono batch --compact`
prints only the failed steps and the summary, as one-line JSON.
`hono snapshot --status-only` captures status codes without bodies —
except the not-found probe line, which keeps its body: a dropped
notFound handler still answers 404, only the body changes.
`hono request --compact` drops the headers and prints one line.

## 2026-09-06: The spec travels in the conversation, not in a file

**Experiment**: the `expect` re-run (`next.4`): a ready-made
Expand Down
26 changes: 26 additions & 0 deletions src/commands/batch/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ describe('batchCommand', () => {
})
})

it('should print only the failed steps and the summary with --compact', async () => {
const fs = await import('node:fs')
vi.mocked(fs.readFileSync).mockReturnValue(
'{"path":"/data","expect":{"status":200}}\n{"path":"/data","expect":{"status":404}}'
)
await program.parseAsync(['node', 'test', 'batch', 'steps.jsonl', 'test-app.js', '--compact'])
const raw = consoleLogSpy.mock.calls[0][0] as string
expect(raw).not.toContain('\n ')
expect(JSON.parse(raw)).toEqual({
ok: true,
data: {
steps: [
{
method: 'GET',
path: '/data',
status: 200,
body: { ok: 1 },
pass: false,
expect: { status: 404 },
},
],
summary: { total: 2, passed: 1, failed: 1 },
},
})
})

it('should reject the app and the batch both from stdin', async () => {
await program.parseAsync(['node', 'test', 'batch', '-', '-'])
const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string)
Expand Down
16 changes: 15 additions & 1 deletion src/commands/batch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ EOF`,
'"save" stores a value from the response body by dot path (e.g. {"id":".id"}), and later steps use it as {{id}}. A whole-variable string like "{{id}}" keeps the saved type.',
'Declare the acceptance criteria in "expect": {"status":201} and/or {"body":{...}} (a deep partial match — declared fields must match, extra response fields are ignored). Turn the spec into batch lines and rerun until "failed" is 0 — comparing a spec table by eye misses lines.',
'A shared header from -H goes to every step. Prefer a heredoc over writing a file: the lines live in your context.',
'--compact prints only the failed steps and the summary — use it when you only need the failed: 0 loop.',
'hono snapshot prints the current behavior of an app in this format — capture before a refactor, rerun after.',
],
}

interface BatchOptions {
header?: string[]
external?: string[]
compact: boolean
}

export function batchCommand(program: Command) {
Expand All @@ -46,6 +48,7 @@ export function batchCommand(program: Command) {
},
[] as string[]
)
.option('--compact', 'Print only the failed steps and the summary', false)
.option(
'-e, --external <package>',
'Mark package as external (can be used multiple times)',
Expand All @@ -68,7 +71,18 @@ export function batchCommand(program: Command) {
const input = source === '-' ? await readStdin() : readBatchFile(source)
const steps = parseBatch(input)
for await (const app of getBuildIterator(file, false, options.external || [])) {
printResult(await runBatch(app, steps, parseHeaders(options.header)))
const result = await runBatch(app, steps, parseHeaders(options.header))
if (options.compact) {
printResult(
{
steps: result.steps.filter((step) => !step.pass),
summary: result.summary,
},
true
)
} else {
printResult(result)
}
}
})
)
Expand Down
13 changes: 13 additions & 0 deletions src/commands/request/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ describe('requestCommand', () => {
expect(output.error.suggestions).toEqual(['hono request /data -X GET'])
})

it('should print one-line JSON without headers with --compact', async () => {
const mockApp = new Hono()
mockApp.get('/data', (c) => c.json({ ok: 1 }))
setupBasicMocks('test-app.js', mockApp)
await program.parseAsync(['node', 'test', 'request', '/data', 'test-app.js', '--compact'])
const raw = consoleLogSpy.mock.calls[0][0]
expect(raw).not.toContain('\n')
expect(JSON.parse(raw)).toEqual({
ok: true,
data: { status: 200, body: { ok: 1 } },
})
})

it('should output a text body as a string in the envelope', async () => {
const mockApp = new Hono()
const text = 'Hello, World!'
Expand Down
33 changes: 22 additions & 11 deletions src/commands/request/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const agentContext: CommandAgentContext = {
'--trace adds matchedRoutes to the output: which middleware and handler matched, and which one responded. Use it to debug an unexpected response. A 404 result includes a suggestion to run it.',
'A JSON response body is embedded as an object. A binary body becomes null with "binary": true — save it with -o.',
'For several requests, or a flow that keeps state, use hono batch. To capture the current behavior of the app, use hono snapshot.',
'--compact prints one-line JSON without the headers — cheaper to read when you only need the status and body.',
],
}

Expand All @@ -56,6 +57,7 @@ interface RequestOptions {
include: boolean
head: boolean
external?: string[]
compact: boolean
}

export function requestCommand(program: Command) {
Expand Down Expand Up @@ -84,6 +86,7 @@ export function requestCommand(program: Command) {
'runtime to execute the app (node | bun | deno | workerd)',
'node'
)
.option('--compact', 'One-line JSON without the headers', false)
.option('-i, --include', 'Include protocol and headers in the output (with --plain)', false)
.option('-I, --head', 'Show only protocol and headers in the output (with --plain)', false)
.option(
Expand Down Expand Up @@ -122,6 +125,11 @@ export function requestCommand(program: Command) {
)
}

if (options.compact && options.plain) {
throw new CliError('INVALID_OPTION', 'Cannot use --compact with --plain', {
suggestions: ['Drop one of them'],
})
}
if (options.trace && options.plain) {
throw new CliError('INVALID_OPTION', 'Cannot use --trace with --plain', {
suggestions: ['Drop --plain. The trace is part of the JSON output'],
Expand Down Expand Up @@ -219,17 +227,20 @@ const printResponse = async (
// it helps, so point at it right there.
const suggestTrace = result.status === 404 && !options.trace && options.runtime === 'node'

printResult({
status: result.status,
headers: result.headers,
body: isBinaryData ? null : parseBody(result.body, contentType),
...(isBinaryData ? { binary: true } : {}),
...(savedTo ? { savedTo } : {}),
...(suggestTrace
? { suggestions: [`See which routes matched: hono request ${path} --trace`] }
: {}),
...extra,
})
printResult(
{
status: result.status,
...(options.compact ? {} : { headers: result.headers }),
body: isBinaryData ? null : parseBody(result.body, contentType),
...(isBinaryData ? { binary: true } : {}),
...(savedTo ? { savedTo } : {}),
...(suggestTrace
? { suggestions: [`See which routes matched: hono request ${path} --trace`] }
: {}),
...extra,
},
options.compact
)
}

const printPlain = (
Expand Down
5 changes: 4 additions & 1 deletion src/commands/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@ export const agentContext: CommandAgentContext = {
'Paramless GET routes are executed and their actual status and body become the "expect". Param and non-GET routes are printed without one, for you to fill in — the tool does not invent intent.',
'One probe line records the current response for a path that matches no route.',
'Capture before a refactor, then rerun the lines with hono batch until "failed" is 0.',
'--status-only captures only the status codes — much smaller on a large app. The probe line keeps its body either way: a dropped notFound handler still answers 404, only the body changes.',
'Unlike routes, this command sends real requests to the app — middleware runs.',
],
}

interface SnapshotOptions {
external?: string[]
statusOnly: boolean
}

export function snapshotCommand(program: Command) {
program
.command('snapshot')
.description('Print the current behavior as batch JSONL lines')
.argument('[file]', 'Path to the Hono app file')
.option('--status-only', 'Capture only the status codes, not the bodies', false)
.option(
'-e, --external <package>',
'Mark package as external (can be used multiple times)',
Expand All @@ -38,7 +41,7 @@ export function snapshotCommand(program: Command) {
.action(
handleErrors(async (file: string | undefined, options: SnapshotOptions) => {
for await (const app of getBuildIterator(file, false, options.external || [])) {
console.log((await snapshotLines(app)).join('\n'))
console.log((await snapshotLines(app, options.statusOnly)).join('\n'))
}
})
)
Expand Down
10 changes: 10 additions & 0 deletions src/commands/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ describe('snapshotLines', () => {
})
})

it('captures only the status with statusOnly, except the probe line', async () => {
const lines = (await snapshotLines(app(), true)).map((l) => JSON.parse(l))
expect(lines).toContainEqual({ path: '/users', expect: { status: 200 } })
expect(lines).toContainEqual({ path: '/health', expect: { status: 200 } })
expect(lines).toContainEqual({
path: '/__no_such_path__',
expect: { status: 404, body: '404 Not Found' },
})
})

it('every line is valid batch input', async () => {
const { parseBatch } = await import('../batch/batch.js')
const lines = await snapshotLines(app())
Expand Down
8 changes: 6 additions & 2 deletions src/commands/snapshot/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,24 @@ import { inspectRoutes } from 'hono/dev'
* printed without an `expect`, for the caller to fill in. One probe
* line records the current not-found behavior as a fact.
*/
export const snapshotLines = async (app: Hono): Promise<string[]> => {
export const snapshotLines = async (app: Hono, statusOnly = false): Promise<string[]> => {
const lines: string[] = []
const routes = inspectRoutes(app).filter((route) => !route.isMiddleware)

for (const route of routes) {
const isParamless = !route.path.includes(':') && !route.path.includes('*')
if (route.method === 'GET' && isParamless) {
lines.push(JSON.stringify({ path: route.path, expect: await capture(app, route.path) }))
const captured = await capture(app, route.path)
const expect = statusOnly ? { status: captured.status } : captured
lines.push(JSON.stringify({ path: route.path, expect }))
} else {
const method = route.method === 'GET' ? {} : { method: route.method }
lines.push(JSON.stringify({ ...method, path: route.path }))
}
}

// The probe keeps its body even with --status-only: a dropped
// notFound handler still answers 404, only the body changes.
lines.push(
JSON.stringify({
path: '/__no_such_path__',
Expand Down
4 changes: 2 additions & 2 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export const formatArgumentsError = (message: string): string => {
return formatError(new CliError('INVALID_ARGUMENTS', cleaned, { suggestions }))
}

export const printResult = (data: unknown): void => {
console.log(formatResult(data))
export const printResult = (data: unknown, compact = false): void => {
console.log(compact ? JSON.stringify({ ok: true, data }) : formatResult(data))
}

/**
Expand Down
Loading