Skip to content

[TypeScript client] feat: RPC & in-memory clients#155

Draft
0xpolarzero wants to merge 54 commits into
wevm:mainfrom
0xpolarzero:typed-client-public-surface
Draft

[TypeScript client] feat: RPC & in-memory clients#155
0xpolarzero wants to merge 54 commits into
wevm:mainfrom
0xpolarzero:typed-client-public-surface

Conversation

@0xpolarzero
Copy link
Copy Markdown

@0xpolarzero 0xpolarzero commented May 27, 2026

Warning

This PR is stacked on top of #153 ([TypeScript client] feat: RPC & in-memory transports). 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 public typed client surface on top of the runtime and transport foundation from PR #8.

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

API

Full API example is available in this gist.

HTTP clients call a served CLI through a HttpTransport, while exposing a command-aware client API:

import { HttpClient } from 'incur/client'
import type { Commands } from './generated/incur-client.js'

const client = HttpClient.create<Commands>({
  baseUrl: 'https://ops.example.com',
  headers: { authorization: 'Bearer token' },
  outputFormat: 'toon',
})

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

console.log(result.data)
console.log(result.output?.text)

The command name, args, options, and data type all come from Commands.

When the CLI is available in the same process, memory clients infer command names, args, and options directly from the concrete CLI:

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

const cli = Cli.create('ops').command('project status', {
  args: z.object({ id: z.string() }),
  output: z.object({ id: z.string(), status: z.string() }),
  run: ({ args }) => ({ id: args.id, status: 'ready' }),
})

const client = MemoryClient.create(cli)

const result = await client.run('project status', {
  args: { id: 'proj_123' },
})

Generated Commands declarations still provide the richest client surface, including declared output data and streaming metadata.

Callers can also create a client directly from a transport factory:

import { Client, HttpTransport } from 'incur/client'

const client = Client.create<Commands>({
  transport: HttpTransport.create({ baseUrl: 'https://ops.example.com' }),
  selection: ['status'],
})

Streaming

Streaming commands return an async iterable for chunks, plus a final promise for the terminal result:

const logs = await client.run('logs tail', {
  args: { service: 'api' },
})

for await (const line of logs) {
  console.log(line)
}

const final = await logs.final
console.log(final.meta.duration)

Callers that need raw protocol records can use records():

for await (const record of logs.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)
}

Resources

The public client exposes resource discovery as methods rather than raw transport.discover(...) requests:

const help = await client.help('project status')
const schema = await client.schema('project status')
const openapi = await client.openapi()
const tools = await client.mcp.tools()

const llmsMarkdown = await client.llms({
  command: 'project',
  format: 'md',
})

Resource scopes are typed from the command map, so command groups such as 'project' and leaf commands such as 'project status' are accepted while unknown scopes are rejected.

Local methods

Memory clients also expose local-only setup methods. These are intentionally not available on HTTP clients.

await client.skills.list()
await client.skills.add({ depth: 1, global: true })
await client.mcp.add({ agents: ['codex'] })

The skills and mcp namespaces merge resource methods and memory-local methods on memory clients:

await client.skills.index()
await client.skills.add()
await client.mcp.tools()
await client.mcp.add()

Type generation

Typegen now emits an exported Commands type and augments both incur and incur/client with the same command map:

export type Commands = {
  'project status': {
    args: { id: string }
    options: { verbose: boolean }
    output: { id: string; status: string }
  }
}

declare module 'incur/client' {
  interface Register {
    commands: Commands
  }
}

That lets users either pass Commands explicitly:

const client = HttpClient.create<Commands>({ baseUrl })

or rely on module augmentation when the generated declarations are loaded:

const client = HttpClient.create({ baseUrl })
await client.run('project status', { args: { id: 'proj_123' } })

Changes

  • Adds the public incur/client namespace surface for Client, HttpClient, MemoryClient, Run, Resources, Local, Rpc, Transport, HttpTransport, and MemoryTransport.
  • Adds Client.create(...), HttpClient.create(...), and MemoryClient.create(...).
  • Adds typed client.run(...) with command-name inference, required input enforcement, exact top-level/inner input key checks, output selection behavior, pagination helpers, CTA follow-up runs, and streaming return types.
  • Adds resource methods for llms, llmsFull, schema, help, openapi, generated skills, and MCP tools.
  • Adds memory-only local methods for generated skill syncing/listing and MCP registration.
  • Moves public run/resource/local/client types into their owning namespace modules instead of a shared type bag.
  • Organizes action implementations under client/actions/*Actions.ts.
  • Updates ClientError and transport error handling to use the canonical Rpc.Error type.
  • Updates typegen to export a reusable Commands type and augment incur/client.
  • Adds incur/client to TypeScript path resolution.

Tests

  • Adds dedicated runtime coverage for Client.create, HttpClient.create, MemoryClient.create, run actions, resource actions, local actions, and transport/client composition.
  • Adds public type tests for Client, HttpClient, MemoryClient, Run, Resources, Local, and Transport.
  • The client test suite covers command input inference, required args/options, extra-key rejection, selected-output fallback to unknown, streaming commands, resource scope narrowing, local-only method visibility, and transport capability types.

Notes

  • This PR intentionally keeps the transport layer from PR fix: native skills fixes #8 intact and builds the public client layer on top.
  • HttpClient.create(...) exposes resource methods but not memory-local methods.
  • MemoryClient.create(cli) exposes the same resource methods and also local skills.add/list and mcp.add.
  • MemoryClient.create(cli) can infer command names, args, and options from a concrete CLI; callers can still pass an explicit command map when they need declared output or stream metadata.
  • Streaming commands reject token pagination controls because stream chunks are consumed incrementally.
  • Resource methods use command scopes derived from the command map, so group scopes such as 'project' narrow LLM manifests to that subtree.

@0xpolarzero 0xpolarzero marked this pull request as draft May 27, 2026 23:41
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