incur 0.1.16 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,274 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { app } from '../test/fixtures/hono-api.js'
4
+ import * as Fetch from './Fetch.js'
5
+
6
+ describe('parseArgv', () => {
7
+ test('bare tokens → path segments', () => {
8
+ const input = Fetch.parseArgv(['users', 'list'])
9
+ expect(input.path).toBe('/users/list')
10
+ })
11
+
12
+ test('empty argv → root path', () => {
13
+ const input = Fetch.parseArgv([])
14
+ expect(input.path).toBe('/')
15
+ })
16
+
17
+ test('single token', () => {
18
+ const input = Fetch.parseArgv(['health'])
19
+ expect(input.path).toBe('/health')
20
+ })
21
+
22
+ test('default method is GET', () => {
23
+ const input = Fetch.parseArgv(['users'])
24
+ expect(input.method).toBe('GET')
25
+ })
26
+
27
+ test('-X sets method', () => {
28
+ const input = Fetch.parseArgv(['users', '-X', 'POST'])
29
+ expect(input.method).toBe('POST')
30
+ })
31
+
32
+ test('--method sets method', () => {
33
+ const input = Fetch.parseArgv(['users', '--method', 'DELETE'])
34
+ expect(input.method).toBe('DELETE')
35
+ })
36
+
37
+ test('default POST when body present', () => {
38
+ const input = Fetch.parseArgv(['users', '--body', '{"name":"Eve"}'])
39
+ expect(input.method).toBe('POST')
40
+ })
41
+
42
+ test('-d sets body', () => {
43
+ const input = Fetch.parseArgv(['users', '-d', '{"name":"Bob"}'])
44
+ expect(input.body).toBe('{"name":"Bob"}')
45
+ expect(input.method).toBe('POST')
46
+ })
47
+
48
+ test('--data sets body', () => {
49
+ const input = Fetch.parseArgv(['users', '--data', '{"x":1}'])
50
+ expect(input.body).toBe('{"x":1}')
51
+ })
52
+
53
+ test('explicit method overrides body default', () => {
54
+ const input = Fetch.parseArgv(['users', '-X', 'PUT', '-d', '{"name":"Bob"}'])
55
+ expect(input.method).toBe('PUT')
56
+ })
57
+
58
+ test('-H sets header', () => {
59
+ const input = Fetch.parseArgv(['users', '-H', 'X-Api-Key: secret'])
60
+ expect(input.headers.get('X-Api-Key')).toBe('secret')
61
+ })
62
+
63
+ test('--header sets header', () => {
64
+ const input = Fetch.parseArgv(['users', '--header', 'Authorization: Bearer tok'])
65
+ expect(input.headers.get('Authorization')).toBe('Bearer tok')
66
+ })
67
+
68
+ test('multiple headers', () => {
69
+ const input = Fetch.parseArgv([
70
+ 'users',
71
+ '-H',
72
+ 'X-A: 1',
73
+ '-H',
74
+ 'X-B: 2',
75
+ ])
76
+ expect(input.headers.get('X-A')).toBe('1')
77
+ expect(input.headers.get('X-B')).toBe('2')
78
+ })
79
+
80
+ test('unknown --key value → query params', () => {
81
+ const input = Fetch.parseArgv(['users', '--limit', '5', '--sort', 'name'])
82
+ expect(input.query.get('limit')).toBe('5')
83
+ expect(input.query.get('sort')).toBe('name')
84
+ })
85
+
86
+ test('--key=value → query params', () => {
87
+ const input = Fetch.parseArgv(['users', '--limit=5'])
88
+ expect(input.query.get('limit')).toBe('5')
89
+ })
90
+
91
+ test('mixed tokens, flags, and query params', () => {
92
+ const input = Fetch.parseArgv([
93
+ 'users',
94
+ '42',
95
+ '--limit',
96
+ '5',
97
+ '-X',
98
+ 'POST',
99
+ '-d',
100
+ '{"x":1}',
101
+ '-H',
102
+ 'Auth: tok',
103
+ ])
104
+ expect(input.path).toBe('/users/42')
105
+ expect(input.method).toBe('POST')
106
+ expect(input.body).toBe('{"x":1}')
107
+ expect(input.query.get('limit')).toBe('5')
108
+ expect(input.headers.get('Auth')).toBe('tok')
109
+ })
110
+ })
111
+
112
+ describe('buildRequest', () => {
113
+ test('builds GET request with path', () => {
114
+ const req = Fetch.buildRequest(Fetch.parseArgv(['users']))
115
+ expect(req.method).toBe('GET')
116
+ expect(new URL(req.url).pathname).toBe('/users')
117
+ })
118
+
119
+ test('builds request with query params', () => {
120
+ const req = Fetch.buildRequest(Fetch.parseArgv(['users', '--limit', '5']))
121
+ const url = new URL(req.url)
122
+ expect(url.searchParams.get('limit')).toBe('5')
123
+ })
124
+
125
+ test('builds POST request with body', () => {
126
+ const req = Fetch.buildRequest(
127
+ Fetch.parseArgv(['users', '-X', 'POST', '-d', '{"name":"Bob"}']),
128
+ )
129
+ expect(req.method).toBe('POST')
130
+ })
131
+
132
+ test('builds request with headers', () => {
133
+ const req = Fetch.buildRequest(
134
+ Fetch.parseArgv(['users', '-H', 'X-Api-Key: secret']),
135
+ )
136
+ expect(req.headers.get('X-Api-Key')).toBe('secret')
137
+ })
138
+ })
139
+
140
+ describe('parseResponse', () => {
141
+ test('parses JSON response', async () => {
142
+ const res = new Response(JSON.stringify({ ok: true }), {
143
+ status: 200,
144
+ headers: { 'content-type': 'application/json' },
145
+ })
146
+ const output = await Fetch.parseResponse(res)
147
+ expect(output.ok).toBe(true)
148
+ expect(output.status).toBe(200)
149
+ expect(output.data).toEqual({ ok: true })
150
+ })
151
+
152
+ test('parses text response', async () => {
153
+ const res = new Response('hello world', { status: 200 })
154
+ const output = await Fetch.parseResponse(res)
155
+ expect(output.data).toBe('hello world')
156
+ })
157
+
158
+ test('error status → ok: false', async () => {
159
+ const res = new Response(JSON.stringify({ message: 'not found' }), {
160
+ status: 404,
161
+ headers: { 'content-type': 'application/json' },
162
+ })
163
+ const output = await Fetch.parseResponse(res)
164
+ expect(output.ok).toBe(false)
165
+ expect(output.status).toBe(404)
166
+ expect(output.data).toEqual({ message: 'not found' })
167
+ })
168
+ })
169
+
170
+ describe('round-trip with Hono', () => {
171
+ test('GET /users', async () => {
172
+ const input = Fetch.parseArgv(['users'])
173
+ const req = Fetch.buildRequest(input)
174
+ const res = await app.fetch(req)
175
+ const output = await Fetch.parseResponse(res)
176
+ expect(output.ok).toBe(true)
177
+ expect(output.data).toEqual({ users: [{ id: 1, name: 'Alice' }], limit: 10 })
178
+ })
179
+
180
+ test('GET /users?limit=5', async () => {
181
+ const input = Fetch.parseArgv(['users', '--limit', '5'])
182
+ const req = Fetch.buildRequest(input)
183
+ const res = await app.fetch(req)
184
+ const output = await Fetch.parseResponse(res)
185
+ expect(output.ok).toBe(true)
186
+ expect(output.data).toEqual({ users: [{ id: 1, name: 'Alice' }], limit: 5 })
187
+ })
188
+
189
+ test('GET /users/:id', async () => {
190
+ const input = Fetch.parseArgv(['users', '42'])
191
+ const req = Fetch.buildRequest(input)
192
+ const res = await app.fetch(req)
193
+ const output = await Fetch.parseResponse(res)
194
+ expect(output.ok).toBe(true)
195
+ expect(output.data).toEqual({ id: 42, name: 'Alice' })
196
+ })
197
+
198
+ test('POST /users with body', async () => {
199
+ const input = Fetch.parseArgv(['users', '-X', 'POST', '-d', '{"name":"Bob"}'])
200
+ const req = Fetch.buildRequest(input)
201
+ const res = await app.fetch(req)
202
+ const output = await Fetch.parseResponse(res)
203
+ expect(output.ok).toBe(true)
204
+ expect(output.status).toBe(201)
205
+ expect(output.data).toEqual({ created: true, name: 'Bob' })
206
+ })
207
+
208
+ test('POST /users with implicit method', async () => {
209
+ const input = Fetch.parseArgv(['users', '--body', '{"name":"Eve"}'])
210
+ const req = Fetch.buildRequest(input)
211
+ const res = await app.fetch(req)
212
+ const output = await Fetch.parseResponse(res)
213
+ expect(output.ok).toBe(true)
214
+ expect(output.data).toEqual({ created: true, name: 'Eve' })
215
+ })
216
+
217
+ test('DELETE /users/:id', async () => {
218
+ const input = Fetch.parseArgv(['users', '1', '--method', 'DELETE'])
219
+ const req = Fetch.buildRequest(input)
220
+ const res = await app.fetch(req)
221
+ const output = await Fetch.parseResponse(res)
222
+ expect(output.ok).toBe(true)
223
+ expect(output.data).toEqual({ deleted: true, id: 1 })
224
+ })
225
+
226
+ test('GET /health', async () => {
227
+ const input = Fetch.parseArgv(['health'])
228
+ const req = Fetch.buildRequest(input)
229
+ const res = await app.fetch(req)
230
+ const output = await Fetch.parseResponse(res)
231
+ expect(output.ok).toBe(true)
232
+ expect(output.data).toEqual({ ok: true })
233
+ })
234
+
235
+ test('GET /error → 404', async () => {
236
+ const input = Fetch.parseArgv(['error'])
237
+ const req = Fetch.buildRequest(input)
238
+ const res = await app.fetch(req)
239
+ const output = await Fetch.parseResponse(res)
240
+ expect(output.ok).toBe(false)
241
+ expect(output.status).toBe(404)
242
+ expect(output.data).toEqual({ message: 'not found' })
243
+ })
244
+
245
+ test('GET /text → plain text', async () => {
246
+ const input = Fetch.parseArgv(['text'])
247
+ const req = Fetch.buildRequest(input)
248
+ const res = await app.fetch(req)
249
+ const output = await Fetch.parseResponse(res)
250
+ expect(output.ok).toBe(true)
251
+ expect(output.data).toBe('hello world')
252
+ })
253
+
254
+ test('custom headers pass through', async () => {
255
+ const input = Fetch.parseArgv(['users', '-H', 'X-Custom: hello'])
256
+ const req = Fetch.buildRequest(input)
257
+ expect(req.headers.get('X-Custom')).toBe('hello')
258
+ const res = await app.fetch(req)
259
+ const output = await Fetch.parseResponse(res)
260
+ expect(output.ok).toBe(true)
261
+ })
262
+
263
+ test('streaming NDJSON response', async () => {
264
+ const input = Fetch.parseArgv(['stream'])
265
+ const req = Fetch.buildRequest(input)
266
+ const res = await app.fetch(req)
267
+ expect(Fetch.isStreamingResponse(res)).toBe(true)
268
+ const chunks: unknown[] = []
269
+ for await (const chunk of Fetch.parseStreamingResponse(res)) {
270
+ chunks.push(chunk)
271
+ }
272
+ expect(chunks).toEqual([{ progress: 1 }, { progress: 2 }])
273
+ })
274
+ })
package/src/Fetch.ts ADDED
@@ -0,0 +1,170 @@
1
+ /** Structured input parsed from curl-style argv. */
2
+ export type FetchInput = {
3
+ body: string | undefined
4
+ headers: Headers
5
+ method: string
6
+ path: string
7
+ query: URLSearchParams
8
+ }
9
+
10
+ /** Structured output from a fetch Response. */
11
+ export type FetchOutput = {
12
+ data: unknown
13
+ headers: Headers
14
+ ok: boolean
15
+ status: number
16
+ }
17
+
18
+ /** Reserved flags consumed by the fetch gateway (not forwarded as query params). */
19
+ const reservedFlags = new Set(['method', 'body', 'data', 'header'])
20
+ const reservedShort: Record<string, string> = { X: 'method', d: 'data', H: 'header' }
21
+
22
+ /** Parses curl-style argv into a structured fetch input. */
23
+ export function parseArgv(argv: string[]): FetchInput {
24
+ const segments: string[] = []
25
+ const headers = new Headers()
26
+ const query = new URLSearchParams()
27
+ let method: string | undefined
28
+ let body: string | undefined
29
+
30
+ let i = 0
31
+ while (i < argv.length) {
32
+ const token = argv[i]!
33
+
34
+ if (token.startsWith('--')) {
35
+ const eqIdx = token.indexOf('=')
36
+ if (eqIdx !== -1) {
37
+ // --key=value
38
+ const key = token.slice(2, eqIdx)
39
+ const value = token.slice(eqIdx + 1)
40
+ if (reservedFlags.has(key)) handleReserved(key, value)
41
+ else query.set(key, value)
42
+ i++
43
+ } else {
44
+ const key = token.slice(2)
45
+ const value = argv[i + 1]
46
+ if (reservedFlags.has(key)) {
47
+ handleReserved(key, value!)
48
+ i += 2
49
+ } else {
50
+ query.set(key, value!)
51
+ i += 2
52
+ }
53
+ }
54
+ } else if (token.startsWith('-') && token.length === 2) {
55
+ const short = token[1]!
56
+ const mapped = reservedShort[short]
57
+ const value = argv[i + 1]!
58
+ if (mapped) {
59
+ handleReserved(mapped, value)
60
+ i += 2
61
+ } else {
62
+ // Unknown short flag — skip (shouldn't happen in fetch context)
63
+ i += 2
64
+ }
65
+ } else {
66
+ segments.push(token)
67
+ i++
68
+ }
69
+ }
70
+
71
+ function handleReserved(key: string, value: string) {
72
+ if (key === 'method') method = value.toUpperCase()
73
+ else if (key === 'body' || key === 'data') body = value
74
+ else if (key === 'header') {
75
+ const colonIdx = value.indexOf(':')
76
+ if (colonIdx !== -1) {
77
+ const name = value.slice(0, colonIdx).trim()
78
+ const val = value.slice(colonIdx + 1).trim()
79
+ headers.set(name, val)
80
+ }
81
+ }
82
+ }
83
+
84
+ return {
85
+ path: segments.length > 0 ? `/${segments.join('/')}` : '/',
86
+ method: method ?? (body !== undefined ? 'POST' : 'GET'),
87
+ headers,
88
+ body,
89
+ query,
90
+ }
91
+ }
92
+
93
+ /** Constructs a standard Request from a FetchInput. */
94
+ export function buildRequest(input: FetchInput): Request {
95
+ const url = new URL(input.path, 'http://localhost')
96
+ input.query.forEach((value, key) => url.searchParams.set(key, value))
97
+
98
+ const init: RequestInit = {
99
+ method: input.method,
100
+ headers: input.headers,
101
+ }
102
+
103
+ if (input.body !== undefined) {
104
+ init.body = input.body
105
+ if (!input.headers.has('content-type'))
106
+ input.headers.set('content-type', 'application/json')
107
+ }
108
+
109
+ return new Request(url.toString(), init)
110
+ }
111
+
112
+ /** Returns true if the response body is a stream that should be consumed incrementally. */
113
+ export function isStreamingResponse(response: Response): boolean {
114
+ return response.body !== null && response.headers.get('content-type') === 'application/x-ndjson'
115
+ }
116
+
117
+ /** Parses a streaming response body as an async generator of parsed NDJSON chunks. */
118
+ export async function* parseStreamingResponse(
119
+ response: Response,
120
+ ): AsyncGenerator<unknown, void, unknown> {
121
+ const reader = response.body!.getReader()
122
+ const decoder = new TextDecoder()
123
+ let buffer = ''
124
+
125
+ while (true) {
126
+ const { value, done } = await reader.read()
127
+ if (done) break
128
+ buffer += decoder.decode(value, { stream: true })
129
+
130
+ let newlineIdx: number
131
+ while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
132
+ const line = buffer.slice(0, newlineIdx).trim()
133
+ buffer = buffer.slice(newlineIdx + 1)
134
+ if (line.length === 0) continue
135
+ try {
136
+ yield JSON.parse(line)
137
+ } catch {
138
+ yield line
139
+ }
140
+ }
141
+ }
142
+
143
+ // flush remaining buffer
144
+ const remaining = buffer.trim()
145
+ if (remaining.length > 0) {
146
+ try {
147
+ yield JSON.parse(remaining)
148
+ } catch {
149
+ yield remaining
150
+ }
151
+ }
152
+ }
153
+
154
+ /** Parses a fetch Response into structured output. */
155
+ export async function parseResponse(response: Response): Promise<FetchOutput> {
156
+ const text = await response.text()
157
+ let data: unknown
158
+ try {
159
+ data = JSON.parse(text)
160
+ } catch {
161
+ data = text
162
+ }
163
+
164
+ return {
165
+ ok: response.ok,
166
+ status: response.status,
167
+ data,
168
+ headers: response.headers,
169
+ }
170
+ }
package/src/Help.test.ts CHANGED
@@ -133,4 +133,52 @@ describe('formatRoot', () => {
133
133
  --verbose Show full output envelope"
134
134
  `)
135
135
  })
136
+
137
+ test('formatRoot shows aliases', () => {
138
+ const result = Help.formatRoot('my-tool', {
139
+ description: 'A test CLI',
140
+ version: '1.0.0',
141
+ aliases: ['mt', 'myt'],
142
+ commands: [{ name: 'fetch', description: 'Fetch a URL' }],
143
+ })
144
+ expect(result).toMatchInlineSnapshot(`
145
+ "my-tool@1.0.0 — A test CLI
146
+ Aliases: mt, myt
147
+
148
+ Usage: my-tool <command>
149
+
150
+ Commands:
151
+ fetch Fetch a URL
152
+
153
+ Global Options:
154
+ --format <toon|json|yaml|md|jsonl> Output format
155
+ --help Show help
156
+ --llms Print LLM-readable manifest
157
+ --verbose Show full output envelope"
158
+ `)
159
+ })
160
+
161
+ test('formatCommand shows aliases', () => {
162
+ const result = Help.formatCommand('my-tool', {
163
+ description: 'A test CLI',
164
+ version: '1.0.0',
165
+ aliases: ['mt', 'myt'],
166
+ args: z.object({ url: z.string().describe('URL to fetch') }),
167
+ })
168
+ expect(result).toMatchInlineSnapshot(`
169
+ "my-tool@1.0.0 — A test CLI
170
+ Aliases: mt, myt
171
+
172
+ Usage: my-tool <url>
173
+
174
+ Arguments:
175
+ url URL to fetch
176
+
177
+ Global Options:
178
+ --format <toon|json|yaml|md|jsonl> Output format
179
+ --help Show help
180
+ --llms Print LLM-readable manifest
181
+ --verbose Show full output envelope"
182
+ `)
183
+ })
136
184
  })
package/src/Help.ts CHANGED
@@ -2,12 +2,13 @@ import { z } from 'zod'
2
2
 
3
3
  /** Formats help text for a router CLI or command group. */
4
4
  export function formatRoot(name: string, options: formatRoot.Options = {}): string {
5
- const { description, version, commands = [], root = false } = options
5
+ const { aliases, description, version, commands = [], root = false } = options
6
6
  const lines: string[] = []
7
7
 
8
8
  // Header
9
- lines.push(description ? `${name} \u2014 ${description}` : name)
10
- if (version) lines.push(`v${version}`)
9
+ const title = version ? `${name}@${version}` : name
10
+ lines.push(description ? `${title} \u2014 ${description}` : title)
11
+ if (aliases?.length) lines.push(`Aliases: ${aliases.join(', ')}`)
11
12
  lines.push('')
12
13
 
13
14
  // Synopsis
@@ -33,6 +34,8 @@ export function formatRoot(name: string, options: formatRoot.Options = {}): stri
33
34
 
34
35
  export declare namespace formatRoot {
35
36
  type Options = {
37
+ /** Alternative binary names for this CLI. */
38
+ aliases?: string[] | undefined
36
39
  /** Commands to list. */
37
40
  commands?: { name: string; description?: string | undefined }[] | undefined
38
41
  /** A short description of the CLI or group. */
@@ -48,6 +51,8 @@ export declare namespace formatCommand {
48
51
  type Options = {
49
52
  /** Map of option names to single-char aliases. */
50
53
  alias?: Record<string, string> | undefined
54
+ /** Alternative binary names for this CLI. */
55
+ aliases?: string[] | undefined
51
56
  /** Zod schema for positional arguments. */
52
57
  args?: z.ZodObject<any> | undefined
53
58
  /** Subcommands to list (for CLIs with both a root handler and subcommands). */
@@ -84,6 +89,7 @@ export declare namespace formatCommand {
84
89
  export function formatCommand(name: string, options: formatCommand.Options = {}): string {
85
90
  const {
86
91
  alias,
92
+ aliases,
87
93
  description,
88
94
  version,
89
95
  args,
@@ -97,8 +103,9 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
97
103
  const lines: string[] = []
98
104
 
99
105
  // Header
100
- lines.push(description ? `${name} \u2014 ${description}` : name)
101
- if (version) lines.push(`v${version}`)
106
+ const title = version ? `${name}@${version}` : name
107
+ lines.push(description ? `${title} \u2014 ${description}` : title)
108
+ if (aliases?.length) lines.push(`Aliases: ${aliases.join(', ')}`)
102
109
  lines.push('')
103
110
 
104
111
  // Synopsis
@@ -163,7 +170,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
163
170
  for (const ex of examples) {
164
171
  const cmd = ex.command ? `$ ${name} ${ex.command}` : `$ ${name}`
165
172
  if (ex.description)
166
- lines.push(` ${cmd}${' '.repeat(maxLen - cmd.length)} ${ex.description}`)
173
+ lines.push(` ${cmd}${' '.repeat(maxLen - cmd.length)} # ${ex.description}`)
167
174
  else lines.push(` ${cmd}`)
168
175
  }
169
176
  }