From 7e629458108e53088b3621befd61df18673ae710 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Thu, 6 Nov 2025 20:28:19 -0700 Subject: [PATCH] feat(cli): add withConditionalBehavior combinator for conditional command behaviors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a composable combinator approach for adding conditional behaviors to commands. Instead of having separate runWith* methods, this provides a single, extensible pattern. Key features: - Command.withConditionalBehavior() wraps commands with conditional behavior - Predicate function determines when behavior should trigger - Supports built-in "wizard" mode or custom behavior functions - Fully composable with other Command combinators via .pipe() - Backwards compatible with existing --wizard flag Example usage: ```typescript const command = Command.make("greet", { name: Options.text("name") }, ({ name }) => Effect.log(`Hello, ${name}!`)) .pipe( Command.withConditionalBehavior( (args) => args.length <= 1, // Run wizard when no args provided "wizard" ) ) Command.run(command, { name: "MyApp", version: "1.0.0" }) ``` This design avoids API explosion (runWithWizard, runWithHelp, etc.) and follows functional composition patterns. The same combinator can be extended for other conditional behaviors like help display, validation, or custom prompting. Fixes #5699 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/soft-oranges-arrive.md | 9 +++ packages/cli/src/Command.ts | 84 +++++++++++++++++++++- packages/cli/src/internal/command.ts | 102 ++++++++++++++++++++++++++- packages/cli/test/Wizard.test.ts | 55 +++++++++++++++ 4 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 .changeset/soft-oranges-arrive.md diff --git a/.changeset/soft-oranges-arrive.md b/.changeset/soft-oranges-arrive.md new file mode 100644 index 00000000000..ac17a41e47d --- /dev/null +++ b/.changeset/soft-oranges-arrive.md @@ -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. diff --git a/packages/cli/src/Command.ts b/packages/cli/src/Command.ts index 9f4d1a2ac98..03a8430cfcc 100644 --- a/packages/cli/src/Command.ts +++ b/packages/cli/src/Command.ts @@ -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" @@ -435,9 +436,88 @@ export const run: { config: Omit, "command"> ): ( self: Command - ) => (args: ReadonlyArray) => Effect + ) => ( + args: ReadonlyArray + ) => Effect ( self: Command, config: Omit, "command"> - ): (args: ReadonlyArray) => Effect + ): ( + args: ReadonlyArray + ) => Effect } = 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) => args.length <= 1 + * const hasHelpFlag = (args: ReadonlyArray) => 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: { + ( + predicate: Predicate>, + behavior: + | "wizard" + | (( + command: Command, + args: ReadonlyArray + ) => Effect, QuitException | ValidationError, FileSystem | Path | Terminal>) + ): ( + self: Command + ) => Command + ( + self: Command, + predicate: Predicate>, + behavior: + | "wizard" + | (( + command: Command, + args: ReadonlyArray + ) => Effect, QuitException | ValidationError, FileSystem | Path | Terminal>) + ): Command +} = Internal.withConditionalBehavior diff --git a/packages/cli/src/internal/command.ts b/packages/cli/src/internal/command.ts index 1ad4c0c44ff..14605845001 100644 --- a/packages/cli/src/internal/command.ts +++ b/packages/cli/src/internal/command.ts @@ -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" @@ -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" @@ -130,6 +132,23 @@ const registeredDescriptors = globalValue( () => new WeakMap, Descriptor.Command>() ) +const conditionalBehaviors = globalValue( + "@effect/cli/Command/conditionalBehaviors", + () => new WeakMap, { + predicate: Predicate.Predicate> + behavior: + | "wizard" + | (( + command: Command.Command, + args: ReadonlyArray + ) => Effect.Effect< + ReadonlyArray, + Terminal.QuitException | ValidationError.ValidationError, + FileSystem.FileSystem | Path.Path | Terminal.Terminal + >) + }>() +) + const getDescriptor = (self: Command.Command) => registeredDescriptors.get(self.tag) ?? self.descriptor @@ -542,13 +561,13 @@ export const run = dual< self: Command.Command ) => ( args: ReadonlyArray - ) => Effect.Effect, + ) => Effect.Effect, ( self: Command.Command, config: Omit, "command"> ) => ( args: ReadonlyArray - ) => Effect.Effect + ) => Effect.Effect >(2, (self, config) => { const app = InternalCliApp.make({ ...config, @@ -556,5 +575,82 @@ export const run = dual< }) 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) => 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) => normalRun(newArgs) + ) + } + + // Predicate didn't match, run normally + return normalRun(args) + } + } + + return normalRun +}) + +/** @internal */ +export const withConditionalBehavior = dual< + ( + predicate: Predicate.Predicate>, + behavior: + | "wizard" + | (( + command: Command.Command, + args: ReadonlyArray + ) => Effect.Effect< + ReadonlyArray, + Terminal.QuitException | ValidationError.ValidationError, + FileSystem.FileSystem | Path.Path | Terminal.Terminal + >) + ) => ( + self: Command.Command + ) => Command.Command, + ( + self: Command.Command, + predicate: Predicate.Predicate>, + behavior: + | "wizard" + | (( + command: Command.Command, + args: ReadonlyArray + ) => Effect.Effect< + ReadonlyArray, + Terminal.QuitException | ValidationError.ValidationError, + FileSystem.FileSystem | Path.Path | Terminal.Terminal + >) + ) => Command.Command +>(3, ( + self: Command.Command, + predicate: Predicate.Predicate>, + behavior: + | "wizard" + | (( + command: Command.Command, + args: ReadonlyArray + ) => Effect.Effect< + ReadonlyArray, + Terminal.QuitException | ValidationError.ValidationError, + FileSystem.FileSystem | Path.Path | Terminal.Terminal + >) +): Command.Command => { + const command: Command.Command = makeDerive(self, {}) + conditionalBehaviors.set(command, { predicate, behavior }) + return command }) diff --git a/packages/cli/test/Wizard.test.ts b/packages/cli/test/Wizard.test.ts index f7e791e8911..d3813986017 100644 --- a/packages/cli/test/Wizard.test.ts +++ b/packages/cli/test/Wizard.test.ts @@ -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)) + }) })