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
9 changes: 9 additions & 0 deletions .changeset/soft-oranges-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@effect/cli": minor
---

Add withConditionalBehavior combinator for composable conditional command behaviors

Introduces a new `withConditionalBehavior` combinator that allows wrapping commands with conditional behaviors based on CLI arguments. This composable approach replaces the need for separate `runWith*` methods and can be used for wizard mode, help display, or any custom behavior.

The combinator accepts Effect's `Predicate` type, enabling the use of predicate combinators like `Predicate.and`, `Predicate.or`, and `Predicate.not` for complex conditional logic.
84 changes: 82 additions & 2 deletions packages/cli/src/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { HashSet } from "effect/HashSet"
import type { Layer } from "effect/Layer"
import type { Option } from "effect/Option"
import { type Pipeable } from "effect/Pipeable"
import type { Predicate } from "effect/Predicate"
import type * as Types from "effect/Types"
import type { Args } from "./Args.js"
import type { CliApp } from "./CliApp.js"
Expand Down Expand Up @@ -435,9 +436,88 @@ export const run: {
config: Omit<CliApp.ConstructorArgs<never>, "command">
): <Name extends string, R, E, A>(
self: Command<Name, R, E, A>
) => (args: ReadonlyArray<string>) => Effect<void, E | ValidationError, R | CliApp.Environment>
) => (
args: ReadonlyArray<string>
) => Effect<void, E | ValidationError | QuitException, R | CliApp.Environment | FileSystem | Path | Terminal>
<Name extends string, R, E, A>(
self: Command<Name, R, E, A>,
config: Omit<CliApp.ConstructorArgs<never>, "command">
): (args: ReadonlyArray<string>) => Effect<void, E | ValidationError, R | CliApp.Environment>
): (
args: ReadonlyArray<string>
) => Effect<void, E | ValidationError | QuitException, R | CliApp.Environment | FileSystem | Path | Terminal>
} = Internal.run

/**
* A combinator that wraps a command to conditionally trigger a behavior (like wizard mode)
* based on a predicate evaluated against the CLI arguments.
*
* This is a composable approach to adding conditional behaviors to commands. Instead of
* having separate `runWith*` methods for each behavior, this single combinator can handle
* wizard mode, help display, or any custom behavior.
*
* The predicate parameter accepts Effect's `Predicate` type, allowing you to use predicate
* combinators like `Predicate.and`, `Predicate.or`, and `Predicate.not` for complex conditions.
*
* @example
* ```typescript
* import { Command, Options } from "@effect/cli"
* import { Effect, Predicate } from "effect"
*
* const command = Command.make("greet", {
* name: Options.text("name")
* }, ({ name }) =>
* Effect.log(\`Hello, \${name}!\`)
* )
*
* // Run wizard mode when no arguments are provided (besides the command name)
* const commandWithWizard = Command.withConditionalBehavior(
* command,
* (args) => args.length <= 1,
* "wizard"
* )
*
* // Or use Predicate combinators for complex conditions
* const hasNoArgs = (args: ReadonlyArray<string>) => args.length <= 1
* const hasHelpFlag = (args: ReadonlyArray<string>) => args.includes("--help")
*
* const commandWithComplexPredicate = Command.withConditionalBehavior(
* command,
* Predicate.or(hasNoArgs, hasHelpFlag),
* "wizard"
* )
*
* const cli = Command.run(commandWithWizard, {
* name: "MyApp",
* version: "1.0.0"
* })
*
* // Running \`mycli\` (no args) will start wizard mode
* // Running \`mycli --name John\` will execute normally
* ```
*
* @since 1.0.0
* @category combinators
*/
export const withConditionalBehavior: {
<Name extends string, R, E, A>(
predicate: Predicate<ReadonlyArray<string>>,
behavior:
| "wizard"
| ((
command: Command<Name, R, E, A>,
args: ReadonlyArray<string>
) => Effect<ReadonlyArray<string>, QuitException | ValidationError, FileSystem | Path | Terminal>)
): (
self: Command<Name, R, E, A>
) => Command<Name, R, E, A>
<Name extends string, R, E, A>(
self: Command<Name, R, E, A>,
predicate: Predicate<ReadonlyArray<string>>,
behavior:
| "wizard"
| ((
command: Command<Name, R, E, A>,
args: ReadonlyArray<string>
) => Effect<ReadonlyArray<string>, QuitException | ValidationError, FileSystem | Path | Terminal>)
): Command<Name, R, E, A>
} = Internal.withConditionalBehavior
102 changes: 99 additions & 3 deletions packages/cli/src/internal/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type * as HashSet from "effect/HashSet"
import type * as Layer from "effect/Layer"
import type * as Option from "effect/Option"
import { pipeArguments } from "effect/Pipeable"
import type * as Predicate from "effect/Predicate"
import type * as Types from "effect/Types"
import type * as Args from "../Args.js"
import type * as CliApp from "../CliApp.js"
Expand All @@ -25,6 +26,7 @@ import type * as Usage from "../Usage.js"
import * as ValidationError from "../ValidationError.js"
import * as InternalArgs from "./args.js"
import * as InternalCliApp from "./cliApp.js"
import * as InternalCliConfig from "./cliConfig.js"
import * as InternalDescriptor from "./commandDescriptor.js"
import * as InternalOptions from "./options.js"

Expand Down Expand Up @@ -130,6 +132,23 @@ const registeredDescriptors = globalValue(
() => new WeakMap<Context.Tag<any, any>, Descriptor.Command<any>>()
)

const conditionalBehaviors = globalValue(
"@effect/cli/Command/conditionalBehaviors",
() => new WeakMap<Command.Command<any, any, any, any>, {
predicate: Predicate.Predicate<ReadonlyArray<string>>
behavior:
| "wizard"
| ((
command: Command.Command<any, any, any, any>,
args: ReadonlyArray<string>
) => Effect.Effect<
ReadonlyArray<string>,
Terminal.QuitException | ValidationError.ValidationError,
FileSystem.FileSystem | Path.Path | Terminal.Terminal
>)
}>()
)

const getDescriptor = <Name extends string, R, E, A>(self: Command.Command<Name, R, E, A>) =>
registeredDescriptors.get(self.tag) ?? self.descriptor

Expand Down Expand Up @@ -542,19 +561,96 @@ export const run = dual<
self: Command.Command<Name, R, E, A>
) => (
args: ReadonlyArray<string>
) => Effect.Effect<void, E | ValidationError.ValidationError, R | CliApp.CliApp.Environment>,
) => Effect.Effect<void, E | ValidationError.ValidationError | Terminal.QuitException, R | CliApp.CliApp.Environment | FileSystem.FileSystem | Path.Path | Terminal.Terminal>,
<Name extends string, R, E, A>(
self: Command.Command<Name, R, E, A>,
config: Omit<CliApp.CliApp.ConstructorArgs<never>, "command">
) => (
args: ReadonlyArray<string>
) => Effect.Effect<void, E | ValidationError.ValidationError, R | CliApp.CliApp.Environment>
) => Effect.Effect<void, E | ValidationError.ValidationError | Terminal.QuitException, R | CliApp.CliApp.Environment | FileSystem.FileSystem | Path.Path | Terminal.Terminal>
>(2, (self, config) => {
const app = InternalCliApp.make({
...config,
command: self.descriptor
})
registeredDescriptors.set(self.tag, self.descriptor)
const handler = (args: any) => self.transform(self.handler(args), args)
return (args) => InternalCliApp.run(app, args, handler)
const normalRun = (args: ReadonlyArray<string>) => InternalCliApp.run(app, args, handler)

// Check if the command has conditional behavior attached
const conditionalBehavior = conditionalBehaviors.get(self)
if (conditionalBehavior) {
const { behavior, predicate } = conditionalBehavior

return (args) => {
// If the predicate matches, run the behavior first
if (predicate(args)) {
// Determine which behavior to run
const behaviorEffect = behavior === "wizard"
? wizard(self, Arr.take(args, 1), InternalCliConfig.defaultConfig)
: behavior(self, args)

// Run the behavior to get new args, then run normally with those args
return Effect.flatMap(
behaviorEffect,
(newArgs: ReadonlyArray<string>) => normalRun(newArgs)
)
}

// Predicate didn't match, run normally
return normalRun(args)
}
}

return normalRun
})

/** @internal */
export const withConditionalBehavior = dual<
<Name extends string, R, E, A>(
predicate: Predicate.Predicate<ReadonlyArray<string>>,
behavior:
| "wizard"
| ((
command: Command.Command<Name, R, E, A>,
args: ReadonlyArray<string>
) => Effect.Effect<
ReadonlyArray<string>,
Terminal.QuitException | ValidationError.ValidationError,
FileSystem.FileSystem | Path.Path | Terminal.Terminal
>)
) => (
self: Command.Command<Name, R, E, A>
) => Command.Command<Name, R, E, A>,
<Name extends string, R, E, A>(
self: Command.Command<Name, R, E, A>,
predicate: Predicate.Predicate<ReadonlyArray<string>>,
behavior:
| "wizard"
| ((
command: Command.Command<Name, R, E, A>,
args: ReadonlyArray<string>
) => Effect.Effect<
ReadonlyArray<string>,
Terminal.QuitException | ValidationError.ValidationError,
FileSystem.FileSystem | Path.Path | Terminal.Terminal
>)
) => Command.Command<Name, R, E, A>
>(3, <Name extends string, R, E, A>(
self: Command.Command<Name, R, E, A>,
predicate: Predicate.Predicate<ReadonlyArray<string>>,
behavior:
| "wizard"
| ((
command: Command.Command<Name, R, E, A>,
args: ReadonlyArray<string>
) => Effect.Effect<
ReadonlyArray<string>,
Terminal.QuitException | ValidationError.ValidationError,
FileSystem.FileSystem | Path.Path | Terminal.Terminal
>)
): Command.Command<Name, R, E, A> => {
const command: Command.Command<Name, R, E, A> = makeDerive(self, {})
conditionalBehaviors.set(command, { predicate, behavior })
return command
})
55 changes: 55 additions & 0 deletions packages/cli/test/Wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,59 @@ describe("Wizard", () => {
const result = Array.some(lines, (line) => line.includes("Quitting wizard mode..."))
expect(result).toBe(true)
}).pipe(runEffect))

describe("withConditionalBehavior", () => {
it("should skip wizard mode when predicate returns false", () =>
Effect.gen(function*() {
let executedWithName: string | undefined
const command = Command.make("greet", {
name: Options.text("name")
}, ({ name }) =>
Effect.sync(() => {
executedWithName = name
})).pipe(
Command.withDescription("Greet someone"),
Command.withConditionalBehavior((args) => args.length <= 1, "wizard")
)

const cli = Command.run(command, {
name: "Test",
version: "1.0.0"
})

// Simulate running with args (should NOT trigger wizard)
const args = Array.make("node", "greet", "--name", "Bob")
yield* cli(args)

// Verify the command was executed with the provided value
expect(executedWithName).toBe("Bob")

const lines = yield* MockConsole.getLines({ stripAnsi: true })
const wizardStarted = Array.some(lines, (line) => line.includes("Wizard Mode") || line.includes("wizard"))
// Wizard should NOT have been started
expect(wizardStarted).toBe(false)
}).pipe(runEffect))

it("should use standard wizard flag when predicate is not met", () =>
Effect.gen(function*() {
const command = Command.make("foo", { message: Options.text("message") }).pipe(
Command.withConditionalBehavior((args) => args.length <= 1, "wizard")
)

const cli = Command.run(command, {
name: "Test",
version: "1.0.0"
})

// Using --wizard flag explicitly (predicate returns false because args.length > 1)
const args = Array.make("node", "test", "--wizard")
const fiber = yield* Effect.fork(cli(args))
yield* MockTerminal.inputKey("c", { ctrl: true })
yield* Fiber.join(fiber)

const lines = yield* MockConsole.getLines({ stripAnsi: true })
const result = Array.some(lines, (line) => line.includes("Quitting wizard mode..."))
expect(result).toBe(true)
}).pipe(runEffect))
})
})