Building a CLI with Effect 4
A command-line tool looks small from the outside: parse some flags, call something, print a line. Then you add subcommands, a flag that every command shares, errors that must go to stderr with the right exit code, a dependency on the operating system that you cannot run in CI, and tests. That plumbing is most of the code.
envsec is a CLI that stores secrets in the OS credential store and their metadata in SQLite. It has 20 top-level commands and is built on Effect 4, whose CLI module now ships inside the effect package as effect/cli. This is a tour of how the pieces fit, with trimmed excerpts from the real code. Every API shown is from Effect 4.0.0, the version envsec depends on; Effect 3's @effect/cli looked different in several places.
For the startup-time side of the same migration, see From 417 to 32 milliseconds.
The shape of the program
Three files do the wiring:
main.tshandles the tab-completion fast path without loading Effect at all, then lazily imports the real CLI.cli-runner.tsassembles the command tree, provides the layers and runs the program.cli/root.tsdefines the root command and its shared flags; most other files incli/define one subcommand each.
The domain code (services, errors, the SQLite store and the three keychain adapters) lives in a separate @envsec/core package, which the SDK and the TUI also use.
A command with flags and arguments
In Effect 4, flags come from the Flag module and positional arguments from Argument. envsec imports them as Options and Args, the module names from @effect/cli, which kept the migration diff small. Here is add, trimmed:
import { Argument as Args, Command, Flag as Options } from "effect/cli";
const keyArg = Args.String("key");
const valueOption = Options.String("value").pipe(
Options.withAlias("v"),
Options.withDescription("Value to store (omit for interactive prompt)"),
Options.optional
);
const expiresOption = Options.String("expires").pipe(
Options.withAlias("e"),
Options.withDescription("Expiry duration (e.g. 30m, 2h, 7d, 4w, 3mo, 1y)"),
Options.optional
);
export const addCommand = Command.make(
"add",
{ key: keyArg, value: valueOption, expires: expiresOption },
({ key, value, expires }) =>
Effect.gen(function* addHandler() {
const ctx = yield* requireContext;
const secret = Option.isSome(value)
? value.value
: yield* readSecret(`${icons.key} Enter secret value: `);
// …validate, parse --expires, then:
yield* SecretStore.set(ctx, key, secret, expiresAt);
})
).pipe(Command.withDescription("Store a secret in a context"));A few things worth noticing:
Command.make(name, config, handler)infers the handler input from the config object.keyis astring, and the two optional flags arrive asOption<string>.- The key order of the config object sets the order in
--help. envsec's linter wants sorted keys, so these objects carry anoxlint-disablecomment. withDefaultturns an optional flag into a plain value;optionalkeeps it as anOption. Every boolean flag in envsec has a default, so handlers never see anOption<boolean>.- Help comes for free.
envsec add --helpprints the description, usage, arguments and flags from these definitions.
Shared flags and reading the parent
--context, --debug, --json and --db apply to every command. They are declared once on the root with Command.withSharedFlags, and a subcommand reads them by yielding the parent command inside its handler:
export const rootCommand = Command.make("envsec").pipe(
Command.withDescription(
"Secure environment secrets management using native OS credential stores"
),
Command.withSharedFlags({
context: contextFlag,
debug: debugFlag,
json: jsonFlag,
db: dbFlag,
})
);
/** Inside any subcommand handler: */
export const isJsonOutput = Effect.gen(function* isJsonOutput() {
const { json } = yield* rootCommand;
return json;
});Shared flags are accepted before or after the subcommand, so envsec -c myapp.dev list and envsec list -c myapp.dev both work. The smoke tests check this, along with a subtler case: add -v means --value, not the global --version.
The context is then validated with a branded Schema, so a bad name fails before any keychain call:
const decodeContext = Schema.decodeEffect(ContextName);
export const requireContext = Effect.gen(function* requireContext() {
const context = yield* rawContext; // --context, then ENVSEC_CONTEXT
if (Option.isNone(context)) {
return yield* Effect.fail(
new Error(
"Missing required option --context (-c) or ENVSEC_CONTEXT env var"
)
);
}
return yield* decodeContext(context.value);
});Flag.withFallbackConfig could read ENVSEC_CONTEXT automatically. I apply it by hand instead, because cmd run needs to tell an explicit --context apart from one inherited from the environment.
Subcommands
The tree is assembled in one place with Command.withSubcommands. Groups nest: cmd is a command with no handler of its own and four children. Aliases are one call: delete uses Command.withAlias("del").
const command = rootCommand.pipe(
Command.withSubcommands([
addCommand,
getCommand,
deleteCommand,
// …17 more
]),
// --debug is a shortcut for --log-level debug.
Command.provide(({ debug }) =>
debug ? Layer.succeed(References.MinimumLogLevel, "Debug") : Layer.empty
)
);
// Nested groups work the same way: `envsec cmd run|search|list|delete`
export const cmdCommand = Command.make("cmd", {}).pipe(
Command.withDescription("Manage saved commands"),
Command.withSubcommands([cmdRunCommand, cmdSearchCommandDef, /* … */])
);Command.provide accepts either a layer or a function from the parsed input to a layer. envsec uses the second form to turn --debug into a minimum log level for the whole run.
Services and layers
Commands never call security, secret-tool or SQLite directly. They talk to a SecretStore service, which depends on two narrower services: KeychainAccess for values and MetadataStore for everything else. In Effect 4, a service is a class that extends Context.Service:
export class KeychainAccess extends Context.Service<
KeychainAccess,
{
readonly set: (
service: string,
account: string,
password: string
) => Effect.Effect<void, KeychainError>;
readonly get: (
service: string,
account: string
) => Effect.Effect<string, SecretNotFoundError | KeychainError>;
readonly remove: (
service: string,
account: string
) => Effect.Effect<void, KeychainError>;
}
>()("envsec/KeychainAccess") {}There are three implementations of this interface, one per OS, and PlatformKeychainAccessLive picks one with a switch on os.platform(). On any other platform it is a layer that fails with UnsupportedPlatformError. The adapters themselves are the subject of One CLI, three keychains.
SecretStore uses the other form of Context.Service, with a make effect, and exposes two layers: one without dependencies and one with the production ones wired in.
export class SecretStore extends Context.Service<SecretStore>()(
"envsec/SecretStore",
{
make: Effect.gen(function* make() {
const keychain = yield* KeychainAccess;
const metadata = yield* MetadataStore;
// …set, get, remove, list, audit queries
return { set, get, remove /* … */ };
}),
}
) {
static readonly layerNoDeps = Layer.effect(this, this.make);
static readonly layer = (
databaseConfig: Layer.Layer<DatabaseConfig> = DatabaseConfigDefault
) =>
this.layerNoDeps.pipe(
Layer.provide(
Layer.merge(
PlatformKeychainAccessLive,
SqliteMetadataStoreLive.pipe(Layer.provide(databaseConfig))
)
)
);
static readonly set = (
context: string,
key: string,
value: string,
expiresAt?: string | null
) => this.use((store) => store.set(context, key, value, expiresAt));
}The static helpers built on this.use let a handler write yield* SecretStore.set(…) instead of first yielding the service. The SQLite layer opens the database with Effect.acquireRelease, so it is closed when the program ends, even on failure.
One wrinkle: --db chooses the database file, but the layer that needs it is built before the CLI parses anything. So db-path.ts reads --db and ENVSEC_DB straight from process.argv and the environment, and passes the result to SecretStore.layer(DatabaseConfigFrom(path)). The flag is still declared on the root so it shows up in --help.
Typed errors
Every domain error is a Schema.TaggedError, all in one module of @envsec/core. They show up in the error channel of each effect's type, and handlers recover from specific ones with Effect.catchTag. get, for example, catches SecretNotFoundError to print a cleanup hint when the metadata exists but the keychain entry is gone.
export class InvalidDurationError extends Schema.TaggedError<InvalidDurationError>()(
"InvalidDurationError",
{ input: Schema.String, message: Schema.String }
) {}
export class CommandExecutionError extends Schema.TaggedError<CommandExecutionError>()(
"CommandExecutionError",
{
command: Schema.String,
exitCode: Schema.Number,
message: Schema.String,
signal: Schema.optional(Schema.String),
}
) {
/** Propagate the child process exit code as the CLI's own exit code. */
override get [Runtime.errorExitCode](): number {
return this.exitCode;
}
/** Ctrl-C is the user stopping the command on purpose: exit quietly. */
override get [Runtime.errorReported](): boolean {
return this.signal !== "SIGINT";
}
}The second class shows two Effect 4 hooks that matter for a CLI. Runtime.errorExitCode sets the process exit code when the error reaches the top, so envsec run exits with the code of the command it ran. Runtime.errorReported controls whether the runtime logs the error at all; Ctrl-C on a child process should not print anything.
Reporting errors to a human
runMain logs any reported failure through the default logger: a timestamp, the fiber id, the error and a stack trace, all on stdout. That is useful for a server and wrong for a CLI whose stdout is often piped into eval or another program. This is what it looks like on a small test CLI:
$# A toy CLI with the default runMain error handling$bun main.ts get nope$[14:19:53.990] ERROR (#1): NotFoundError: No secret "nope"$ at get (…/vault.ts:24:27)$ at runLoop (…/effect/dist/internal/effect.js:463:86)$ …envsec wraps the whole program in reportErrors. It prints the error message as one line on stderr, then fails with a marker error whose errorReported is false, so runMain only applies the exit code. Parse errors that effect/cli has already rendered with the help text pass through untouched. Unexpected defects get the full cause.
export const reportErrors = <A, E, R>(
effect: Effect.Effect<A, E, R>
): Effect.Effect<A, unknown, R> =>
effect.pipe(
Effect.catchCause((cause): Effect.Effect<never, unknown> => {
if (Cause.hasInterruptsOnly(cause)) {
return Effect.failCause(cause);
}
const squashed = Cause.squash(cause);
if (!Runtime.getErrorReported(squashed)) {
return Effect.failCause(cause); // already rendered by effect/cli
}
const failure = Cause.findErrorOption(cause);
const output = Option.isSome(failure)
? `${icons.error} ${messageOf(failure.value)}`
: `${icons.error} Unexpected error:\n${Cause.pretty(cause)}`;
return Console.error(output).pipe(
Effect.andThen(
Effect.fail(
new ReportedFailureError(Runtime.getErrorExitCode(squashed))
)
)
);
})
);$envsec -c myapp.dev get nope$✖ Secret metadata not found: myapp.dev/nope$envsec -c myapp.dev run "exit 3"$✖ Command exited with code 3$echo $?$3Running it
Command.runWith takes the command and a version string and returns a function from an argument array to an effect. Command.run does the same but reads the arguments from the Stdio service. The rest is providing layers and handing the effect to runMain:
const cli = Command.runWith(command, {
version: envsecVersion,
})(process.argv.slice(2));
export const runCliWithLayer = (
cachePath: string,
secretStoreLayer: Layer.Layer<SecretStore, unknown>
): void => {
// …completion interception elided
cli.pipe(
Effect.provide(secretStoreLayer),
Effect.provide(nodeServicesLayer),
// Logs are diagnostics: keep them off stdout, which carries data.
Effect.provideService(References.LogToStderr, true),
reportErrors,
runMain
);
};The platform layer is the Node implementation of the services effect/cli needs: file system, path, stdio, terminal and child processes. Effect's own examples use NodeServices.layer from @effect/platform-node. envsec builds the same layer from deep imports of @effect/platform-node-shared, because that package barrel also loads modules a CLI never uses:
import { layer as childProcessSpawnerLayer } from "@effect/platform-node-shared/NodeChildProcessSpawner";
import { layer as cryptoLayer } from "@effect/platform-node-shared/NodeCrypto";
import { layer as fileSystemLayer } from "@effect/platform-node-shared/NodeFileSystem";
import { layer as pathLayer } from "@effect/platform-node-shared/NodePath";
import { layer as stdioLayer } from "@effect/platform-node-shared/NodeStdio";
import { layer as terminalLayer } from "@effect/platform-node-shared/NodeTerminal";
export { runMain } from "@effect/platform-node-shared/NodeRuntime";
/** Equivalent to `NodeServices.layer` from @effect/platform-node. */
export const nodeServicesLayer = Layer.provideMerge(
childProcessSpawnerLayer,
Layer.mergeAll(fileSystemLayer, cryptoLayer, pathLayer, stdioLayer, terminalLayer)
);The same code runs on Node from npm and as a standalone Bun binary. Nothing in it is Bun-specific.
Testing
The service boundary is what makes envsec testable. A test never touches the real keychain: it provides SecretStore.layerNoDeps with an in-memory KeychainAccess and a temporary SQLite file. This is from packages/core/test/secret-store.test.mjs:
const memoryKeychain = (entries = new Map()) =>
Layer.succeed(KeychainAccess, {
get: (service, account) => {
const value = entries.get(`${service}/${account}`);
return value === undefined
? Effect.fail(new SecretNotFoundError({ /* … */ }))
: Effect.succeed(value);
},
remove: (service, account) =>
Effect.sync(() => { entries.delete(`${service}/${account}`); }),
set: (service, account, password) =>
Effect.sync(() => { entries.set(`${service}/${account}`, password); }),
});
const storeLayer = (databasePath, keychain = memoryKeychain()) =>
SecretStore.layerNoDeps.pipe(
Layer.provide(
Layer.merge(
keychain,
SqliteMetadataStoreLive.pipe(
Layer.provide(DatabaseConfigFrom(databasePath))
)
)
)
);It is also why runCliWithLayer takes the SecretStore layer as a parameter. The end-to-end script runs the full CLI through a small entry point, test/e2e-main.mjs, that passes in a layer backed by a JSON file instead of the OS keychain. That covers add, get, list, search, env-file, load, delete, run and cmd on any machine, without leaving entries in a real keychain.
The CLI smoke tests take the outside view: they spawn the built dist/main.js with a temporary --db and assert on stdout, stderr and the exit status. One of them checks that an invalid context leaves stdout empty and exits with 1.
envsec's tests use node:test against the compiled output. If you want to test a command in-process instead, Command.runWith takes the arguments directly, and the platform services can be replaced with test layers. This minimal example runs with bun test on Effect 4.0.0; vault is a two-command toy CLI with the same structure as envsec, and memoryKeyStore its in-memory service:
import { expect, test } from "bun:test";
import { Effect, FileSystem, Layer, Path, Stdio, Terminal } from "effect";
import { Command } from "effect/cli";
import { ChildProcessSpawner } from "effect/process";
const CliTestLayer = Layer.mergeAll(
FileSystem.layerNoop({}),
Path.layer,
Stdio.layerTest({}),
Layer.succeed(Terminal.Terminal, Terminal.make({
columns: Effect.succeed(80),
rows: Effect.succeed(24),
readInput: Effect.die("unused"),
readLine: Effect.die("unused"),
display: () => Effect.void,
})),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.die("unused"))
)
);
test("add stores the value", async () => {
const entries = new Map<string, string>();
const run = Command.runWith(vault, { version: "0.1.0" });
await Effect.runPromise(
run(["add", "api.key", "--value", "s3cret"]).pipe(
Effect.provide(memoryKeyStore(entries)),
Effect.provide(CliTestLayer)
)
);
expect(entries.get("api.key")).toBe("s3cret");
});Trade-offs
- The CLI module is marked unstable. The Effect 4 source tags these functions
@stability unstable, so I pin the exact version: envsec depends oneffect4.0.0, not a range. - Loading code is not free. On my machine, loading and initialising envsec's modules took about 116 ms on Node and 28 ms in the Bun binary. That is why tab completion in envsec reads a JSON cache in
main.tsand only imports the CLI on a cache miss. - Some things still live outside the parser.
--dbis read fromargvbefore parsing, and--completionsis intercepted so envsec can print its own dynamic scripts instead of the static oneseffect/cligenerates.
In short
Command.make plus Flag and Argument give you typed input and generated help. withSharedFlags and yielding the parent command handle global options. Context.Service and layers keep the OS out of your handlers, which is what makes the CLI testable. Schema.TaggedError with the Runtime markers gives you exit codes, and a small wrapper around runMain keeps errors on stderr. The full source is on GitHub, and Effect's own CLI example is a good first read.