diff --git a/README.md b/README.md index f78d84b..8ed4a77 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ hono build # Show routes of your Hono app hono routes + +# Show how to use Hono CLI, for coding agents +hono agent-context ``` ## Commands @@ -33,6 +36,7 @@ hono routes - `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` @@ -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 diff --git a/src/cli.ts b/src/cli.ts index d4ac5b9..6345125 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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' @@ -23,5 +24,6 @@ program buildCommand(program) requestCommand(program) routesCommand(program) +agentContextCommand(program) program.parse() diff --git a/src/commands/agent-context/document.ts b/src/commands/agent-context/document.ts new file mode 100644 index 0000000..a0b070a --- /dev/null +++ b/src/commands/agent-context/document.ts @@ -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 = { + 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 ` — 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()])) + ) + ) diff --git a/src/commands/agent-context/index.test.ts b/src/commands/agent-context/index.test.ts new file mode 100644 index 0000000..a44ce9a --- /dev/null +++ b/src/commands/agent-context/index.test.ts @@ -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 + + beforeEach(() => { + program = new Command() + buildCommand(program) + requestCommand(program) + routesCommand(program) + agentContextCommand(program) + consoleLogSpy = spyOnLog() + }) + + afterEach(() => { + consoleLogSpy.mockRestore() + vi.restoreAllMocks() + }) + + const getOutput = async (): Promise => { + 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 `') + 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') + }) +}) diff --git a/src/commands/agent-context/index.ts b/src/commands/agent-context/index.ts new file mode 100644 index 0000000..8ae874a --- /dev/null +++ b/src/commands/agent-context/index.ts @@ -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)) + }) +} diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts index d67f9f3..8403975 100644 --- a/src/commands/build/index.ts +++ b/src/commands/build/index.ts @@ -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'] diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index d1fc4f8..87957dc 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -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 diff --git a/src/commands/routes/index.ts b/src/commands/routes/index.ts index 7af45b1..a6282b1 100644 --- a/src/commands/routes/index.ts +++ b/src/commands/routes/index.ts @@ -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 diff --git a/src/utils/agent-context.ts b/src/utils/agent-context.ts new file mode 100644 index 0000000..f6d1eec --- /dev/null +++ b/src/utils/agent-context.ts @@ -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[] +} diff --git a/src/utils/markdown.test.ts b/src/utils/markdown.test.ts new file mode 100644 index 0000000..57b0278 --- /dev/null +++ b/src/utils/markdown.test.ts @@ -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```') + }) +}) diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts new file mode 100644 index 0000000..cae482c --- /dev/null +++ b/src/utils/markdown.ts @@ -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')