From c1ef5a4472333b6b42d5679c3f64c5119c96328f Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Wed, 29 Jul 2026 11:17:00 -0400 Subject: [PATCH] Add CLI output presenter --- .changeset/add-cli-output-presenter.md | 5 + packages/effect/src/unstable/cli/CliOutput.ts | 143 +++++++++++++++++- packages/effect/src/unstable/cli/Command.ts | 16 +- .../effect/src/unstable/cli/GlobalFlag.ts | 17 ++- .../effect/test/unstable/cli/Command.test.ts | 59 ++++++++ 5 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 .changeset/add-cli-output-presenter.md diff --git a/.changeset/add-cli-output-presenter.md b/.changeset/add-cli-output-presenter.md new file mode 100644 index 00000000000..941ddaf2b7d --- /dev/null +++ b/.changeset/add-cli-output-presenter.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add a scoped `CliOutput.Presenter` service for suppressing, redirecting, or replacing built-in CLI help, invalid-invocation, and version output. diff --git a/packages/effect/src/unstable/cli/CliOutput.ts b/packages/effect/src/unstable/cli/CliOutput.ts index e65ccdb5842..55c34c184d9 100644 --- a/packages/effect/src/unstable/cli/CliOutput.ts +++ b/packages/effect/src/unstable/cli/CliOutput.ts @@ -1,16 +1,16 @@ /** - * Formats CLI help and errors as text. + * Formats and presents CLI help, errors, and version information. * - * This module turns help documents, CLI errors, grouped errors, and version - * information into strings. It does not write those strings to the terminal - * itself. It includes the `Formatter` interface, the formatter service, a layer - * for custom formatters, and the default formatter with configurable color - * support. + * The `Formatter` service turns structured values into strings. The `Presenter` + * service handles semantic output events and can be replaced to suppress, + * redirect, or customize CLI output. * * @since 4.0.0 */ +import * as Console from "../../Console.ts" import * as Context from "../../Context.ts" +import * as Effect from "../../Effect.ts" import * as Layer from "../../Layer.ts" import * as Option from "../../Option.ts" import type * as CliError from "./CliError.ts" @@ -197,6 +197,80 @@ export interface Formatter { readonly formatErrors: (errors: ReadonlyArray) => string } +/** + * Handles semantic CLI output events. + * + * **When to use** + * + * Use when you need to replace how command help, invalid invocations, or + * version information are presented without changing their text formatter. + * + * @see {@link Formatter} for converting structured CLI output into text + * @category models + * @since 4.0.0 + */ +export interface Presenter { + /** + * Presents a semantic CLI output event. + * + * @since 4.0.0 + */ + readonly present: (event: Presenter.Event) => Effect.Effect +} + +/** + * Types used by the `Presenter` service. + * + * @since 4.0.0 + */ +export declare namespace Presenter { + /** + * Semantic output events produced while running a CLI command. + * + * @category models + * @since 4.0.0 + */ + export type Event = Help | InvalidInvocation | Version + + /** + * Help output for an explicit or implicit help request. + * + * @category models + * @since 4.0.0 + */ + export interface Help { + readonly _tag: "Help" + readonly reason: "Requested" | "Implicit" + readonly commandPath: ReadonlyArray + readonly helpDoc: HelpDoc + } + + /** + * Help and diagnostics for an invalid command invocation. + * + * @category models + * @since 4.0.0 + */ + export interface InvalidInvocation { + readonly _tag: "InvalidInvocation" + readonly commandPath: ReadonlyArray + readonly helpDoc: HelpDoc + readonly errors: ReadonlyArray + } + + /** + * Version output for a CLI command. + * + * @category models + * @since 4.0.0 + */ + export interface Version { + readonly _tag: "Version" + readonly name: string + readonly version: string + } +} + /** * Service reference for the CLI output formatter. Provides a default implementation * that can be overridden for custom formatting or testing. @@ -230,6 +304,49 @@ export const Formatter: Context.Reference = Context.Reference( { defaultValue: () => defaultFormatter() } ) +/** + * Default presenter that writes formatted CLI output through `Console`. + * + * **Details** + * + * Help and version events are written with `Console.log`. Invalid invocations + * write help with `Console.log` and diagnostics with `Console.error`. + * + * @see {@link Presenter} for replacing presentation behavior + * @category defaults + * @since 4.0.0 + */ +export const defaultPresenter: Presenter = { + present: Effect.fnUntraced(function*(event) { + const formatter = yield* Formatter + switch (event._tag) { + case "Help": + return yield* Console.log(formatter.formatHelpDoc(event.helpDoc)) + case "InvalidInvocation": + yield* Console.log(formatter.formatHelpDoc(event.helpDoc)) + return yield* Console.error(formatter.formatErrors(event.errors)) + case "Version": + return yield* Console.log(formatter.formatVersion(event.name, event.version)) + } + }) +} + +/** + * Context reference for presenting semantic CLI output events. + * + * **When to use** + * + * Use when you need to suppress, redirect, or replace built-in CLI output. + * + * @see {@link defaultPresenter} for the default console-based behavior + * @category services + * @since 4.0.0 + */ +export const Presenter: Context.Reference = Context.Reference( + "effect/cli/CliOutput/Presenter", + { defaultValue: () => defaultPresenter } +) + /** * Creates a Layer that provides a custom Formatter implementation. * @@ -269,6 +386,20 @@ export const Formatter: Context.Reference = Context.Reference( */ export const layer = (formatter: Formatter): Layer.Layer => Layer.succeed(Formatter)(formatter) +/** + * Creates a layer that provides a custom `Presenter` implementation. + * + * **When to use** + * + * Use when you want to configure CLI presentation as part of an application + * layer. + * + * @see {@link Presenter} for providing the service directly + * @category layers + * @since 4.0.0 + */ +export const layerPresenter = (presenter: Presenter): Layer.Layer => Layer.succeed(Presenter)(presenter) + /** * Creates a default formatter with configurable options. * diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 79cd0a9b0e5..24c13ede382 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -1448,12 +1448,22 @@ const showHelp = ( ): Effect.Effect => Effect.gen(function*() { const { builtIns } = yield* CliConfig.CliConfig - const formatter = yield* CliOutput.Formatter + const presenter = yield* CliOutput.Presenter const helpDoc = yield* getHelpForCommandPath(command, error.commandPath, builtIns) - yield* Console.log(formatter.formatHelpDoc(helpDoc)) if (error.errors.length > 0) { - yield* Console.error(formatter.formatErrors(error.errors as any)) + return yield* presenter.present({ + _tag: "InvalidInvocation", + commandPath: error.commandPath, + helpDoc, + errors: error.errors + }) } + return yield* presenter.present({ + _tag: "Help", + reason: "Implicit", + commandPath: error.commandPath, + helpDoc + }) }) /** diff --git a/packages/effect/src/unstable/cli/GlobalFlag.ts b/packages/effect/src/unstable/cli/GlobalFlag.ts index a300d4f419d..a6847c9d744 100644 --- a/packages/effect/src/unstable/cli/GlobalFlag.ts +++ b/packages/effect/src/unstable/cli/GlobalFlag.ts @@ -158,9 +158,14 @@ export const Help: Action = action({ Flag.withDescription("Show help information") ), run: Effect.fnUntraced(function*(_, { builtIns, command, commandPath }) { - const formatter = yield* CliOutput.Formatter + const presenter = yield* CliOutput.Presenter const helpDoc = yield* HelpInternal.getHelpForCommandPath(command, commandPath, builtIns) - yield* Console.log(formatter.formatHelpDoc(helpDoc)) + yield* presenter.present({ + _tag: "Help", + reason: "Requested", + commandPath, + helpDoc + }) }) }) @@ -180,8 +185,12 @@ export const Version: Action = action({ Flag.withDescription("Show version information") ), run: Effect.fnUntraced(function*(_, { command, version }) { - const formatter = yield* CliOutput.Formatter - yield* Console.log(formatter.formatVersion(command.name, version)) + const presenter = yield* CliOutput.Presenter + yield* presenter.present({ + _tag: "Version", + name: command.name, + version + }) }) }) diff --git a/packages/effect/test/unstable/cli/Command.test.ts b/packages/effect/test/unstable/cli/Command.test.ts index ca3fadf0671..b8d4d0a79ad 100644 --- a/packages/effect/test/unstable/cli/Command.test.ts +++ b/packages/effect/test/unstable/cli/Command.test.ts @@ -115,6 +115,65 @@ describe("Command", () => { })) }) + describe("presentation", () => { + it.effect("should delegate built-in output to the configured presenter", () => + Effect.gen(function*() { + const events: Array = [] + const presenter: CliOutput.Presenter = { + present: (event) => Effect.sync(() => events.push(event)) + } + const command = Command.make("greet", { + name: Flag.string("name") + }, () => Effect.void) + const run = Command.runWith(command, { version: "1.0.0" }) + const runImplicitHelp = Command.runWith(Command.make("empty"), { version: "1.0.0" }) + + const [failure, implicitHelp] = yield* Effect.gen(function*() { + yield* run(["--help"]) + yield* run(["--version"]) + const failure = yield* Effect.flip(run([])) + const implicitHelp = yield* Effect.flip(runImplicitHelp([])) + return [failure, implicitHelp] as const + }).pipe(Effect.provide(CliOutput.layerPresenter(presenter))) + + assert.strictEqual(failure._tag, "ShowHelp") + assert.strictEqual(implicitHelp._tag, "ShowHelp") + assert.deepStrictEqual( + events.map((event) => { + switch (event._tag) { + case "Help": + return { + tag: event._tag, + reason: event.reason, + commandPath: event.commandPath + } + case "InvalidInvocation": + return { + tag: event._tag, + commandPath: event.commandPath, + errors: event.errors.map((error) => error._tag) + } + case "Version": + return { + tag: event._tag, + name: event.name, + version: event.version + } + } + }), + [ + { tag: "Help", reason: "Requested", commandPath: ["greet"] }, + { tag: "Version", name: "greet", version: "1.0.0" }, + { tag: "InvalidInvocation", commandPath: ["greet"], errors: ["MissingOption"] }, + { tag: "Help", reason: "Implicit", commandPath: ["empty"] } + ] + ) + + assert.deepStrictEqual(yield* TestConsole.logLines, []) + assert.deepStrictEqual(yield* TestConsole.errorLines, []) + }).pipe(Effect.provide(TestLayer))) + }) + describe("run", () => { it.effect("should invoke the wizard programmatically from a command handler", () => Effect.gen(function*() {