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
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@ hono build

# Show routes of your Hono app
hono routes

# Show how to use Hono CLI, for coding agents
hono agent-context
```

## Commands

- `request [file]` - Send request to Hono app using `app.request()`
- `build [entry]` - Build your Hono app
- `routes [file]` - Show routes of your Hono app
- `agent-context` - Show how to use Hono CLI, for coding agents

### `request`

Expand Down Expand Up @@ -217,23 +221,25 @@ hono routes [file] [options]
}
```

## Tips
### `agent-context`

### Using Hono CLI with AI Code Agents
Show how to use Hono CLI, as Markdown for coding agents. The content is generated from the command definitions, so it always matches the installed version.

```bash
hono agent-context
```

When working with AI code agents like Claude Code, you can configure them to use the `hono` CLI for testing. Add the following to your project's `CLAUDE.md` or similar configuration:
## Tips

````markdown
## Hono Development
### Using Hono CLI with AI Code Agents

Use the `hono` CLI for efficient development. View all commands with `hono --help`.
Add one line to your project's `AGENTS.md` or `CLAUDE.md`:

```bash
# Test your app without starting a server
hono request -P /api/users src/index.ts
hono request -P /api/users -X POST -d '{"name":"Alice"}' src/index.ts
```markdown
Working on this Hono app? Run `hono agent-context` first and follow it.
```
````

The agent reads the output and learns the whole workflow: `routes` to see the app, `request` to test it, and `build` to bundle it.

## Authors

Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { agentContextCommand } from './commands/agent-context/index.js'
import { buildCommand } from './commands/build/index.js'
import { requestCommand } from './commands/request/index.js'
import { routesCommand } from './commands/routes/index.js'
Expand All @@ -23,5 +24,6 @@ program
buildCommand(program)
requestCommand(program)
routesCommand(program)
agentContextCommand(program)

program.parse()
80 changes: 80 additions & 0 deletions src/commands/agent-context/document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Command } from 'commander'
import type { CommandAgentContext } from '../../utils/agent-context.js'
import { bullets, codeBlock, section, steps } from '../../utils/markdown.js'
import { agentContext as buildContext } from '../build/index.js'
import { agentContext as requestContext } from '../request/index.js'
import { agentContext as routesContext } from '../routes/index.js'

const contexts: Record<string, CommandAgentContext> = {
routes: routesContext,
request: requestContext,
build: buildContext,
}

const commandDoc = (command: Command, context?: CommandAgentContext): string => {
const args = command.registeredArguments
.map((arg) => (arg.required ? `<${arg.name()}>` : `[${arg.name()}]`))
.join(' ')

const optionLines = command.options.map((option) => {
const defaultValue =
option.defaultValue === undefined || option.defaultValue === false
? ''
: ` (default: ${JSON.stringify(option.defaultValue)})`
return `\`${option.flags}\` — ${option.description}${defaultValue}`
})

const errors = context?.errors ?? []
const examples = context?.examples ?? []
const notes = context?.notes ?? []

return section(
3,
`hono ${command.name()}${args ? ` ${args}` : ''}`,
command.description(),
optionLines.length > 0 && 'Options:',
optionLines.length > 0 && bullets(optionLines),
context?.output && `Output \`data\`: \`${context.output}\``,
errors.length > 0 && `Error codes: ${errors.map((code) => `\`${code}\``).join(', ')}`,
examples.length > 0 && 'Examples:',
examples.length > 0 && codeBlock('bash', examples),
notes.length > 0 && bullets(notes)
)
}

export const renderAgentContext = (program: Command): string =>
section(
1,
'Hono CLI',
'Hono CLI (`hono`) is a command-line tool for coding agents working on a ' +
'[Hono](https://hono.dev) app. It loads the app directly, so you can inspect ' +
'and test it without starting a server.',
section(
2,
'Output contract',
'Every command prints JSON to stdout:',
bullets([
'Success: `{ "ok": true, "data": ... }` with exit code 0',
'Failure: `{ "ok": false, "error": { "code", "message", "hint" } }` with exit code 1',
]),
'Follow `error.hint` when a command fails. Logs go to stderr. Add `--plain` ' +
'when a human wants to read the output.'
),
section(
2,
'Recommended workflow',
steps([
'`hono routes` — get all routes of the app without reading the source',
'`hono request -P <path>` — send a request to the app without a server',
'After you change the app, run them again to verify',
'`hono build` — bundle the app (`--optimize` to reduce size)',
])
),
section(
2,
'Commands',
...program.commands
.filter((command) => command.name() !== 'agent-context')
.map((command) => commandDoc(command, contexts[command.name()]))
)
)
62 changes: 62 additions & 0 deletions src/commands/agent-context/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Command } from 'commander'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { buildCommand } from '../build/index.js'
import { requestCommand } from '../request/index.js'
import { routesCommand } from '../routes/index.js'
import { agentContextCommand } from './index.js'

describe('agentContextCommand', () => {
let program: Command
const spyOnLog = () => vi.spyOn(console, 'log').mockImplementation(() => {})
let consoleLogSpy: ReturnType<typeof spyOnLog>

beforeEach(() => {
program = new Command()
buildCommand(program)
requestCommand(program)
routesCommand(program)
agentContextCommand(program)
consoleLogSpy = spyOnLog()
})

afterEach(() => {
consoleLogSpy.mockRestore()
vi.restoreAllMocks()
})

const getOutput = async (): Promise<string> => {
await program.parseAsync(['node', 'hono', 'agent-context'])
return consoleLogSpy.mock.calls[0][0]
}

it('should print Markdown with the output contract and workflow', async () => {
const output = await getOutput()
expect(output).toContain('# Hono CLI')
expect(output).toContain('## Output contract')
expect(output).toContain('"ok": true')
expect(output).toContain('## Recommended workflow')
})

it('should document every command except itself', async () => {
const output = await getOutput()
expect(output).toContain('### hono build [entry]')
expect(output).toContain('### hono request [file]')
expect(output).toContain('### hono routes [file]')
expect(output).not.toContain('### hono agent-context')
})

it('should include options from the command definitions', async () => {
const output = await getOutput()
expect(output).toContain('`--optimize`')
expect(output).toContain('`-P, --path <path>`')
expect(output).toContain('`--verbose`')
})

it('should include declared output shapes, error codes, and examples', async () => {
const output = await getOutput()
expect(output).toContain('`ENTRY_NOT_FOUND`')
expect(output).toContain('`INVALID_APP`')
expect(output).toContain('"router": "SmartRouter + RegExpRouter"')
expect(output).toContain('hono build --optimize')
})
})
11 changes: 11 additions & 0 deletions src/commands/agent-context/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Command } from 'commander'
import { renderAgentContext } from './document.js'

export function agentContextCommand(program: Command) {
program
.command('agent-context')
.description('Show how to use Hono CLI, for coding agents')
.action(async () => {
console.log(renderAgentContext(program))
})
}
12 changes: 12 additions & 0 deletions src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,22 @@ import { buildInitParams, serializeInitParams } from 'hono/router/reg-exp-router
import { execFile } from 'node:child_process'
import { existsSync, realpathSync, statSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import type { CommandAgentContext } from '../../utils/agent-context.js'
import { buildAndImportApp } from '../../utils/build.js'
import { CliError, handleErrors, printResult } from '../../utils/output.js'
import { removeApis } from './remove-apis.js'

export const agentContext: CommandAgentContext = {
output:
'{ "optimized": true, "router": "PreparedRegExpRouter", "removed": { "requestBodyApis": true, "contextResponseApis": ["html"], "honoApis": ["route"] }, "output": "dist/index.js", "size": 34124 }',
errors: ['ENTRY_NOT_FOUND', 'INVALID_OPTION'],
examples: ['hono build', 'hono build --optimize', 'hono build --optimize -m -o dist/app.js'],
notes: [
'Without --optimize it just bundles the app.',
'With --optimize, request body APIs are removed only when every route method is strictly GET/HEAD/OPTIONS. If you have checked the app never reads request bodies, pass --request-body-api-removal force.',
],
}

const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx']

const HONO_REMOVAL_METHODS = ['route', 'mount', 'fire']
Expand Down
16 changes: 16 additions & 0 deletions src/commands/request/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import type { Command } from 'commander'
import type { Hono } from 'hono'
import type { CommandAgentContext } from '../../utils/agent-context.js'
import { getFilenameFromPath, saveFile } from '../../utils/file.js'
import { getBuildIterator } from '../../utils/load-app.js'
import { handleErrors, printResult } from '../../utils/output.js'

export const agentContext: CommandAgentContext = {
output:
'{ "status": 200, "headers": { "content-type": "application/json" }, "body": { "message": "Hello" } }',
errors: ['ENTRY_NOT_FOUND'],
examples: [
'hono request -P /api/users',
`hono request -P /api/users -X POST -d '{"name":"Alice"}'`,
'hono request -P /image.png -o image.png',
],
notes: [
'No server needed. The request goes directly to app.request().',
'A JSON response body is embedded as an object. A binary body becomes null with "binary": true — save it with -o.',
],
}

interface RequestOptions {
method?: string
data?: string
Expand Down
12 changes: 12 additions & 0 deletions src/commands/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import type { Command } from 'commander'
import { getRouterName, inspectRoutes } from 'hono/dev'
import type { CommandAgentContext } from '../../utils/agent-context.js'
import { getBuildIterator } from '../../utils/load-app.js'
import { CliError, handleErrors, printResult } from '../../utils/output.js'

export const agentContext: CommandAgentContext = {
output:
'{ "router": "SmartRouter + RegExpRouter", "routes": [{ "method": "GET", "path": "/", "name": "[handler]", "isMiddleware": false }] }',
errors: ['ENTRY_NOT_FOUND', 'INVALID_APP'],
examples: ['hono routes', 'hono routes --verbose src/app.ts'],
notes: [
'Routes are resolved from the real app instance, so mounted sub-apps and basePath are all expanded.',
'Run it first to get the full picture of an app without reading the source.',
],
}

interface RoutesOptions {
verbose: boolean
plain: boolean
Expand Down
14 changes: 14 additions & 0 deletions src/utils/agent-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Extra information a command declares for `hono agent-context`.
* Keep it next to the command so it stays correct.
*/
export interface CommandAgentContext {
/** Shape of `data` in the JSON output, as a compact sample */
output?: string
/** Error codes this command can return */
errors?: string[]
/** Usage examples, one command line each */
examples?: string[]
/** Extra notes for agents */
notes?: string[]
}
20 changes: 20 additions & 0 deletions src/utils/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest'
import { section, bullets, steps, codeBlock } from './markdown'

describe('markdown', () => {
it('should join blocks with blank lines and skip conditional blocks', () => {
const result = section(1, 'Title', 'First.', false, null, undefined, 'Second.')
expect(result).toBe('# Title\n\nFirst.\n\nSecond.')
})

it('should nest sections', () => {
const result = section(1, 'Top', section(2, 'Sub', 'Body.'))
expect(result).toBe('# Top\n\n## Sub\n\nBody.')
})

it('should render bullets, steps, and code blocks', () => {
expect(bullets(['a', 'b'])).toBe('- a\n- b')
expect(steps(['a', 'b'])).toBe('1. a\n2. b')
expect(codeBlock('bash', ['echo hi'])).toBe('```bash\necho hi\n```')
})
})
23 changes: 23 additions & 0 deletions src/utils/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Small helpers to build a Markdown document. A block is a string;
// false/null/undefined blocks are skipped, so conditional blocks can be
// written inline.

export type Block = string | false | null | undefined

const joinBlocks = (blocks: Block[]): string =>
blocks
.filter((block): block is string => typeof block === 'string')
.map((block) => block.trim())
.filter((block) => block.length > 0)
.join('\n\n')

export const section = (level: 1 | 2 | 3, title: string, ...blocks: Block[]): string =>
`${'#'.repeat(level)} ${title}\n\n${joinBlocks(blocks)}`

export const bullets = (items: string[]): string => items.map((item) => `- ${item}`).join('\n')

export const steps = (items: string[]): string =>
items.map((item, index) => `${index + 1}. ${item}`).join('\n')

export const codeBlock = (lang: string, lines: string[]): string =>
['```' + lang, ...lines, '```'].join('\n')
Loading