Skip to content

registry ​

Tier 1 · engines. Depends on rpc-protocol for the envelope shapes and exit codes it re-exports. zod v4 is a peer.

sh
npm install @titan-design/registry zod

The problem it solves ​

A tool that has a CLI, an MCP server, and an HTTP API usually has three descriptions of every command: a commander definition, a JSON Schema for MCP, and a route handler. They drift, and the drift is invisible until a user hits it.

Define the command once with zod. The CLI, the MCP tool list, and the HTTP route become projections of that one definition.

When to reach for it ​

You are building anything with more than one surface, or you expect a second surface later. It is a small package and cheap to adopt: it depends only on rpc-protocol and zod (peer), and the binding pattern keeps your call sites unchanged.

Example ​

Verified against 0.1.0.

ts
import { z } from "zod";
import {
  EXIT,
  commandToTool,
  createRegistry,
  defineCommand,
  invokeCommand,
  toolNameToCommandName,
  type BaseContext,
} from "@titan-design/registry";

interface Ctx extends BaseContext {
  root: string; // product-specific fields extend BaseContext
}

const registry = createRegistry<Ctx>();

const taskDone = defineCommand<{ slug: string; force?: boolean }, { ok: true }, Ctx>({
  name: "task.done",
  description: "Mark a task done",
  args: z.object({ slug: z.string(), force: z.boolean().optional() }),
  result: z.object({ ok: z.literal(true) }),
  cli: { positional: ["slug"], options: { force: { long: "--force", description: "Skip checks" } } },
  async run(args) {
    return { ok: true };
  },
});

registry.register(taskDone);

const ctx = { warnings: [], format: "json", root: "/tmp/x" } satisfies Ctx;

await invokeCommand(taskDone, { slug: "TP-1" }, ctx, { invalidArgsCode: EXIT.USAGE });
// { envelope: { ok: true, data: { ok: true } }, exitCode: 0 }
// (a `warnings` array appears only when the command pushed one onto ctx.warnings)

await invokeCommand(taskDone, { slug: 3 }, ctx, { invalidArgsCode: EXIT.USAGE });
// { envelope: { ok: false,
//               error: 'Invalid arguments: slug: Invalid input: expected string, received number',
//               code: 64 }, exitCode: 64 }

commandToTool(taskDone, { prefix: "miner__" }).name;             // 'miner__task__done'
toolNameToCommandName("miner__task__done", { prefix: "miner__" }); // 'task.done'

invokeCommand validates, runs, and always returns an envelope. It never throws. Thrown errors map to a code through their numeric code property by default; pass formatError to use your own error hierarchy.

The projections ​

CLI. Commander-free helpers, so the product owns the commander wiring: commandPath("task.done") gives the sub-command path, positionalSpec gives <slug> or [slug] from the schema, optionFlagSpec gives --force or --x <value>, and collectCliArgs reads commander's parsed values back into an args record, coerced by schema kind. An array-typed field's flag repeats (--tag a --tag b yields ["a", "b"]); collectOptionParser gives commander's accumulator for it, and each element is coerced by the array's element kind.

MCP. commandToTool builds the descriptor; inputSchema comes from zod 4's native toJSONSchema with $schema and definitions stripped, because some MCP clients reject them at the root. Tool names are ${prefix}${command.replaceAll(".", "__")}.

HTTP. daemon mounts POST /rpc/:name over the same invokeCommand, so the envelope and exit codes are identical on every surface.

Browser. CommandMapOf<typeof commands> turns commands keyed by name into the CommandMap that rpc-client is generic over. It is type-only, so a front end imports it with import type and neither zod nor this package reaches the bundle. The key is the command name: defineCommand widens name to string, so the type cannot check that the two agree.

Gotchas ​

createRegistry() returns an instance, not a module singleton. Each product owns its registry. register throws on a duplicate name.

list() is sorted by name, so help output and tool lists do not depend on import order. If you are migrating from an insertion-ordered map, your golden files will move.

A command cannot be widened to a context it does not accept. run is a function-typed property, not a method, so its context is checked contravariantly. A command whose run reads ctx.root cannot be assigned to Command<…, BaseContext>, AnyCommand or a base-context registry. The reverse works: a base-context command serves any product registry. Code that reads a command but never calls run takes AnyCommand<never>, which accepts every context. Up to 0.3.x run was a method, and its bivariant parameter let such a command typecheck anywhere.

Commander's --no-* negation stores false under the positive key and never defines the noX key. collectCliArgs handles it. This is not hypothetical: a real product shipped a --no-loops flag that was inert for exactly this reason, and the regression test for it lives in this package.

Where it came from ​

active-work's src/registry/ (~65 lines at extraction, zod to CLI/MCP/HTTP) — the cleanest unit in the audit. active-work now consumes it; see the adoption case study.

MIT licensed