Skip to content

[TypeScript client] feat: RPC & in-memory transports#153

Draft
0xpolarzero wants to merge 27 commits into
wevm:mainfrom
0xpolarzero:typed-client-runtime-foundation
Draft

[TypeScript client] feat: RPC & in-memory transports#153
0xpolarzero wants to merge 27 commits into
wevm:mainfrom
0xpolarzero:typed-client-runtime-foundation

Conversation

@0xpolarzero
Copy link
Copy Markdown

@0xpolarzero 0xpolarzero commented May 27, 2026

Warning

This PR is stacked on top of #154 (fix: reuse CLI skill projection). Because wevm/incur does not have that base branch and this PR targets main, the diff includes the commits from that prerequisite branch as well.

This PR adds the runtime and transport foundation that lets TypeScript code call an incur CLI through the same structured command protocol, whether the CLI is served over HTTP or used in process.

Check the PR on my fork for reviewing from a stacked diff.

API

import { Cli, z } from 'incur'
import { MemoryTransport } from 'incur/client'

const cli = Cli.create('app').command('project status', {
  args: z.object({ id: z.string() }),
  options: z.object({ verbose: z.boolean().default(false) }),
  run: ({ args, options }) => ({
    id: args.id,
    status: 'ready',
    verbose: options.verbose,
  }),
})

const transport = MemoryTransport.create(cli)()

const result = await transport.request({
  command: 'project status',
  args: { id: 'proj_123' },
  options: { verbose: true },
})

if (!('stream' in result) && result.ok) {
  console.log(result.data)
}

The same request shape works against a served CLI:

import { HttpTransport } from 'incur/client'

const transport = HttpTransport.create({
  baseUrl: 'https://example.com',
  headers: { authorization: 'Bearer token' },
})()

const result = await transport.request({
  command: 'project status',
  args: { id: 'proj_123' },
  options: { verbose: true },
  outputFormat: 'json',
  selection: ['status'],
  outputTokenCount: true,
  outputTokenLimit: 500,
})

HTTP clients can also call the RPC endpoint directly:

const response = await fetch('https://example.com/_incur/rpc', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    command: 'project status',
    args: { id: 'proj_123' },
    options: { verbose: true },
    outputFormat: 'json',
  }),
})

const result = await response.json()

Streaming commands return NDJSON over HTTP and an async record iterator in transports:

const result = await transport.request({ command: 'logs tail' })

if ('stream' in result) {
  for await (const record of result.records()) {
    if (record.type === 'chunk') console.log(record.data)
    if (record.type === 'done') console.log(record.data, record.meta)
    if (record.type === 'error') console.error(record.error)
  }
}

Discovery uses the same transport contract for help, schemas, OpenAPI, skills, LLM manifests, and MCP tools:

await transport.discover({ resource: 'help', command: 'project status' })
await transport.discover({ resource: 'schema', command: 'project status' })
await transport.discover({ resource: 'llms' })
await transport.discover({ resource: 'llmsFull', format: 'json' })
await transport.discover({ resource: 'openapi', format: 'yaml' })
await transport.discover({ resource: 'skillsIndex' })
await transport.discover({ resource: 'skill', name: 'project-status' })
await transport.discover({ resource: 'mcpTools' })

Memory transports also expose local-only setup helpers:

await transport.local.skills.list()
await transport.local.skills.add({ depth: 1 })
await transport.local.mcp.add({ command: 'app' })

Changes

  • Adds the incur/client export with ClientError, Request, Discover, Local, Transport, HttpTransport, and MemoryTransport.
  • Adds a shared client request runtime that executes canonical command IDs with structured args and options.
  • Adds a memory transport for in-process CLIs without going through cli.fetch().
  • Adds an HTTP transport that posts structured requests to /_incur/rpc.
  • Adds shared discovery for help, schemas, OpenAPI, LLM manifests, generated skills, and MCP tools.
  • Adds streaming support that preserves chunk, terminal done, terminal error, metadata, retryability, and cancellation.
  • Adds ClientError so transport and discovery failures carry code, status, data, fieldErrors, and metadata when available.

Notes

  • This is the runtime/transport layer, not the final high-level typed client API. Callers use transport.request({ command, args, options }); a later PR builds the public generated/client-facing API on top.
  • Structured RPC accepts canonical command IDs only. Aliases are rejected as unknown commands, command groups return COMMAND_GROUP, and raw fetch gateways return FETCH_GATEWAY.
  • Runtime execution preserves command resolution, Zod validation, middleware, env validation, vars, formatted output, output selections, token metadata, CTAs, and streaming terminal records.
  • MemoryTransport.create(cli, { env }) uses explicit env and does not load config defaults.
  • HttpTransport sends OpenAPI discovery to /openapi.json or /openapi.yaml; other discovery resources use /_incur/*.

Notable side effects

  • Typegen now includes callable root commands. Previously Typegen.fromCli() only walked the subcommand map, so a root CLI created with Cli.create('name', { run }) could be missing from generated command declarations.
  • Typegen now uses the shared runtime context's structured command collection, so generated command declarations follow the same root-command, alias, fetch-gateway, and group traversal rules as RPC/discovery.
  • Typegen now emits exact optional properties (?: T | undefined) and escapes generated command/property keys when they are not valid bare TypeScript identifiers.
  • Typegen now emits declared command output schemas and stream: true markers for async generator commands.
  • Cli.command('api', { fetch, openapi }) now generates OpenAPI-backed command groups synchronously, so mounted OpenAPI operations are available before serving.
  • OpenAPI-mounted operations are now reflected in the CLI command map type via Openapi.Commands<name, spec>.
  • Openapi.generateCommands() now includes JSON response schemas as command output schemas when available.
  • OpenAPI fallback command names changed from underscore-style names to space-delimited command names, for example get users posts.
  • Command.execute() now supports parseMode: 'structured' for RPC requests where args and options are already separated.
  • Several CLI internals and guards are exported so the runtime context can reuse the CLI's command-entry shapes instead of duplicating them.
  • Route coverage moved into the broader CLI and transport tests, with added assertions for RPC status mapping, discovery error envelopes, duration metadata, NDJSON parsing, truncated streams, and cancellation.

@0xpolarzero 0xpolarzero marked this pull request as draft May 27, 2026 15:10
@0xpolarzero 0xpolarzero force-pushed the typed-client-runtime-foundation branch from 97a9ab0 to e2182b7 Compare May 27, 2026 16:48
@0xpolarzero 0xpolarzero force-pushed the typed-client-runtime-foundation branch from e2182b7 to c0ddef3 Compare May 27, 2026 17:21
@0xpolarzero 0xpolarzero force-pushed the typed-client-runtime-foundation branch from c0ddef3 to e1db698 Compare May 27, 2026 17:23
@0xpolarzero 0xpolarzero force-pushed the typed-client-runtime-foundation branch from e1db698 to 2f25ed9 Compare May 27, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant