All posts
7 min readDavid Nussio

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:

  1. main.ts handles the tab-completion fast path without loading Effect at all, then lazily imports the real CLI.
  2. cli-runner.ts assembles the command tree, provides the layers and runs the program.
  3. cli/root.ts defines the root command and its shared flags; most other files in cli/ 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:

ts
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:

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:

ts
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:

ts
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").

ts
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:

ts
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.

ts
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.

ts
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:

Terminal
# 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.

ts
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))
          )
        )
      );
    })
  );
Terminal
$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 $?
$3

Running 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:

ts
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:

ts
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:

js
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:

ts
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

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.