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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

- **`z.output<>` over `z.infer<>`** — use `z.output<schema>` for types after transforms/defaults are applied (what `schema.parse()` returns at runtime). Use `z.input<schema>` only when representing pre-validation types.
- **`const` generics on definitions** — any function that accepts Zod schemas and passes them to callbacks must use `const` generic parameters to preserve literal types (e.g. `<const args extends z.ZodObject<any>>`).
- **Streaming commands use `async *run`** — typed client/typegen stream detection is based on the handler being an async generator function. Do not hide streaming behind `run() { return stream() }` when generated client types should be streaming-aware.
- **Flow schemas through generics** — when a factory function accepts Zod schemas, use generics to flow `z.output<>` through to callbacks (`run`, `next`), return types, and constraint types (`alias`). Never fall back to `any` in callback signatures.
- **Type tests in `.test-d.ts`** — use vitest's `expectTypeOf` in colocated `.test-d.ts` files to assert generic inference works. Type tests are first-class — write them alongside implementation, not as an afterthought.
- **Avoid global declaration merging in type tests** — module augmentation in `.test-d.ts` affects the full `tsc -b` project, so prefer explicit generics/local helper types unless the test is specifically about global registration behavior.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ cli.command('deploy', {

### Streaming

Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text:
Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text. Generated client types recognize streaming commands from this `async *run` shape:

```ts
cli.command('logs', {
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ Bun.serve(cli)

## Streaming

Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text:
Use `async *run` to stream chunks incrementally. Yield objects for structured data or plain strings for text. Generated client types recognize streaming commands from this `async *run` shape:

```ts
cli.command('logs', {
Expand Down
214 changes: 214 additions & 0 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4372,6 +4372,147 @@ describe('fetch', () => {
])
})

test('POST /_incur/rpc streams async generator output as NDJSON', async () => {
const cli = Cli.create('test')
cli.command('stream', {
args: z.object({ start: z.number() }),
options: z.object({ step: z.number() }),
async *run(c) {
yield { progress: c.args.start }
yield { progress: c.args.start + c.options.step }
return { done: c.args.start + c.options.step * 2 }
},
})
const res = await cli.fetch(
new Request('http://localhost/_incur/rpc', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
command: 'stream',
args: { start: 2 },
options: { step: 3 },
}),
}),
)

expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/x-ndjson')
const lines = (await res.text())
.trim()
.split('\n')
.map((l) => JSON.parse(l))
expect(lines).toMatchInlineSnapshot(`
[
{
"data": {
"progress": 2,
},
"type": "chunk",
},
{
"data": {
"progress": 5,
},
"type": "chunk",
},
{
"meta": {
"command": "stream",
},
"ok": true,
"type": "done",
},
]
`)
})

test('POST /_incur/rpc stream preserves returned ok CTA', async () => {
const cli = Cli.create('test')
cli.command('stream', {
async *run(c) {
yield { progress: 1 }
return c.ok(undefined, { cta: { commands: ['status'] } })
},
})
const res = await cli.fetch(
new Request('http://localhost/_incur/rpc', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command: 'stream', args: {}, options: {} }),
}),
)

const lines = (await res.text())
.trim()
.split('\n')
.map((l) => JSON.parse(l))
expect(lines).toMatchInlineSnapshot(`
[
{
"data": {
"progress": 1,
},
"type": "chunk",
},
{
"meta": {
"command": "stream",
"cta": {
"commands": [
{
"command": "test status",
},
],
"description": "Suggested command:",
},
},
"ok": true,
"type": "done",
},
]
`)
})

test('POST /_incur/rpc stream preserves returned errors', async () => {
const cli = Cli.create('test')
cli.command('stream', {
async *run(c) {
yield { progress: 1 }
return c.error({ code: 'STREAM_FAIL', message: 'broke' })
},
})
const res = await cli.fetch(
new Request('http://localhost/_incur/rpc', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command: 'stream', args: {}, options: {} }),
}),
)

const lines = (await res.text())
.trim()
.split('\n')
.map((l) => JSON.parse(l))
expect(lines).toMatchInlineSnapshot(`
[
{
"data": {
"progress": 1,
},
"type": "chunk",
},
{
"error": {
"code": "STREAM_FAIL",
"message": "broke",
},
"ok": false,
"type": "error",
},
]
`)
})

test('trailing path segments → positional args', async () => {
const cli = Cli.create('test')
cli.command('users', {
Expand Down Expand Up @@ -4496,6 +4637,79 @@ describe('fetch', () => {
`)
})

test('async generator RPC stream preserves returned ok CTA', async () => {
const cli = Cli.create('test')
cli.command('stream', {
async *run(c) {
yield { progress: 1 }
return c.ok(undefined, { cta: { commands: ['status'] } })
},
})
const res = await cli.fetch(new Request('http://localhost/stream'))
const lines = (await res.text())
.trim()
.split('\n')
.map((l) => JSON.parse(l))
expect(lines).toMatchInlineSnapshot(`
[
{
"data": {
"progress": 1,
},
"type": "chunk",
},
{
"meta": {
"command": "stream",
"cta": {
"commands": [
{
"command": "test status",
},
],
"description": "Suggested command:",
},
},
"ok": true,
"type": "done",
},
]
`)
})

test('async generator RPC stream preserves returned errors', async () => {
const cli = Cli.create('test')
cli.command('stream', {
async *run(c) {
yield { progress: 1 }
return c.error({ code: 'STREAM_FAIL', message: 'broke' })
},
})
const res = await cli.fetch(new Request('http://localhost/stream'))
const lines = (await res.text())
.trim()
.split('\n')
.map((l) => JSON.parse(l))
expect(lines).toMatchInlineSnapshot(`
[
{
"data": {
"progress": 1,
},
"type": "chunk",
},
{
"error": {
"code": "STREAM_FAIL",
"message": "broke",
},
"ok": false,
"type": "error",
},
]
`)
})

test('middleware sets var → command sees it', async () => {
const cli = Cli.create('test', {
vars: z.object({ user: z.string().default('anonymous') }),
Expand Down
Loading