Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-cli-output-presenter.md
Original file line number Diff line number Diff line change
@@ -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.
143 changes: 137 additions & 6 deletions packages/effect/src/unstable/cli/CliOutput.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -197,6 +197,80 @@ export interface Formatter {
readonly formatErrors: (errors: ReadonlyArray<CliError.CliError>) => 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<void>
}

/**
* 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<string>
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<string>
readonly helpDoc: HelpDoc
readonly errors: ReadonlyArray<CliError.NonShowHelpErrors>
}

/**
* 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.
Expand Down Expand Up @@ -230,6 +304,49 @@ export const Formatter: Context.Reference<Formatter> = 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<Presenter> = Context.Reference(
"effect/cli/CliOutput/Presenter",
{ defaultValue: () => defaultPresenter }
)

/**
* Creates a Layer that provides a custom Formatter implementation.
*
Expand Down Expand Up @@ -269,6 +386,20 @@ export const Formatter: Context.Reference<Formatter> = Context.Reference(
*/
export const layer = (formatter: Formatter): Layer.Layer<never> => 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<never> => Layer.succeed(Presenter)(presenter)

/**
* Creates a default formatter with configurable options.
*
Expand Down
16 changes: 13 additions & 3 deletions packages/effect/src/unstable/cli/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,12 +1448,22 @@ const showHelp = <Name extends string, Input, E, R, ContextInput>(
): Effect.Effect<void, CliError.CliError, Environment> =>
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
})
})

/**
Expand Down
17 changes: 13 additions & 4 deletions packages/effect/src/unstable/cli/GlobalFlag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,14 @@ export const Help: Action<boolean> = 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
})
})
})

Expand All @@ -180,8 +185,12 @@ export const Version: Action<boolean> = 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
})
})
})

Expand Down
59 changes: 59 additions & 0 deletions packages/effect/test/unstable/cli/Command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CliOutput.Presenter.Event> = []
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*() {
Expand Down
Loading