Skip to content

CLI

An Api’s typed client works from Effect code. A person at a terminal, a shell script or a coding agent wants a command line instead. toCli from @alchemy.run/domain/Cli builds an Effect CLI command from the Api you already have. It calls the deployed Api over its HTTP protocol, so the server runs the same middleware and checks as for every other client.

packages/cli/src/bank.ts
import * as Domain from "@alchemy.run/domain";
import { toCli } from "@alchemy.run/domain/Cli";
import { BunRuntime, BunServices } from "@effect/platform-bun";
import * as Command from "effect/cli/Command";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
import { CustomerApi } from "@bank/domain/CustomerApi.api";
const bank = toCli(CustomerApi, {
name: "bank",
// Only these calls get a subcommand.
expose: ["DepositMoney", "WithdrawMoney", "AccountLedger", "AccountSummary.get", "Statement.list"],
// The session middleware runs on the server; the CLI sends its token.
headers: Config.redacted("BANK_TOKEN").pipe(
Effect.map((token) => ({ authorization: `Bearer ${Redacted.value(token)}` })),
),
});
Command.run(bank, { version: "1.0.0" }).pipe(
Effect.provide(
Layer.mergeAll(
Domain.protocolHttp("https://bank.example.com/api").pipe(Layer.provide(FetchHttpClient.layer)),
BunServices.layer,
),
),
BunRuntime.runMain,
);

toCli is its own module, so an app that builds no CLI never loads effect/cli.

Each subcommand’s help is the description in the annotations of the declaration it calls. The root help is the Api’s own description, unless you pass toCli a description of its own.

packages/domain/src/CustomerApi.api.ts
export class CustomerApi extends Domain.Api(
"CustomerApi",
{ model: Bank, commands: ["DepositMoney", "WithdrawMoney"], views: ["AccountSummary", "Statement"] },
{ description: "The bank, as a customer reaches it." },
) {}
Terminal window
$ bank --help
The bank, as a customer reaches it.
deposit-money Put money into an account. The amount is in cents.
withdraw-money Take money out of an account. The amount is in cents.
account-summary An account's balance and owner.
statement Read the Statement view.

A declaration without a description gets a generated sentence, like statement here.

Each command is a subcommand in kebab-case, and its payload and input fields are flags. It prints the receipt as JSON:

Terminal window
$ bank deposit-money --target a-1 --amount 1000
{
"reply": { "balance": 1000 },
"stream": "Account/a-1",
"version": 1
}

--json takes the same fields as one object, and any flag you also give wins over it:

Terminal window
$ bank deposit-money --json '{"target":"a-1","amount":1000}' --amount 50

It also takes the call’s own wire payload, with the input nested, as the HTTP API and MCP tools take it:

Terminal window
$ bank deposit-money --json '{"target":"a-1","input":{"amount":1000}}'

A view, a stream or an aggregate is a subcommand with one subcommand per read, get, peek, list or snapshot:

Terminal window
$ bank account-summary get --key a-1
{ "balance": 1000 }
$ bank statement list --key a-1 --order desc --take 20

watch and events stream, so they aren’t commands.

A query is a subcommand, like a command. Its input fields are flags, next to --after, --take and --at-least:

Terminal window
$ bank account-ledger --account a-1 --take 20
{
"entries": [{ "id": "…", "amount": 1000 }],
"cursor": null
}

Pass the cursor back as --after for the next page.

A string, a number, a boolean or a set of string literals takes a typed flag. Anything else, such as a struct, an array or a date, takes its JSON:

Terminal window
$ bank account-summary get --key a-1 --at-least '{"stream":"Account/a-1","version":1}'

A command whose input has a field named like a payload field, such as its own target, takes its whole input as --input JSON instead.

The CLI decodes the input against the call’s schema before it sends anything, so a bad flag fails without a request:

Terminal window
$ bank deposit-money --amount 1000 # exit code 1, nothing sent
# stderr: Invalid input for deposit-money, naming the missing `target`

A rejection, or an error of the call’s middleware, goes to stderr as its wire JSON, and the run fails with CallRejected, exiting with code 1:

Terminal window
$ bank withdraw-money --target a-1 --amount 5000 # exit code 1
{ "_tag": "InsufficientFunds", "balance": 1000, "requested": 5000 }

Every run dies with a DefinitionError when two things the Api serves get one command name, or when a field would take a flag the CLI reserves (--json, --help, --version, --wizard, --completions, --log-level):

class Reserved extends Domain.Command("Reserved", {
target: AccountId,
input: { json: Schema.String },
}) {}
// bank reserved ... -> DefinitionError: field `json` would be the flag --json, which the CLI reserves

A call that expose leaves out has no subcommand.