From 6e7696b1d554eb0289462b08a800c5600692d1bc Mon Sep 17 00:00:00 2001 From: Zelys Date: Wed, 6 May 2026 21:06:22 -0500 Subject: [PATCH 1/2] feat(@effect/cli): add withOptionalValue combinator to Options --- .changeset/cli-optional-value.md | 17 ++++ packages/cli/src/Options.ts | 25 ++++++ packages/cli/src/internal/options.ts | 46 +++++++++-- packages/cli/test/Options.test.ts | 113 +++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 .changeset/cli-optional-value.md diff --git a/.changeset/cli-optional-value.md b/.changeset/cli-optional-value.md new file mode 100644 index 00000000000..1d7112ac4b0 --- /dev/null +++ b/.changeset/cli-optional-value.md @@ -0,0 +1,17 @@ +--- +"@effect/cli": minor +--- + +Add `withOptionalValue` combinator to `@effect/cli` Options, allowing a +CLI option to be specified on the command line without a value. When the +flag is present but not followed by a value the provided `fallback` is +used; when a value is provided it is used as normal; when the flag is +absent entirely the option behaves as before (required unless wrapped with +`optional()` or `withDefault()`). + +```ts +// --log-level → "info" (fallback) +// --log-level debug → "debug" (supplied value) +// (flag absent) → error: Expected to find option '--log-level' +const logLevel = Options.text("log-level").pipe(Options.withOptionalValue("info")) +``` diff --git a/packages/cli/src/Options.ts b/packages/cli/src/Options.ts index 4a01a4a8f8d..c1823ed31f0 100644 --- a/packages/cli/src/Options.ts +++ b/packages/cli/src/Options.ts @@ -556,6 +556,31 @@ export const withPseudoName: { (self: Options, pseudoName: string): Options } = InternalOptions.withPseudoName +/** + * Allows the option to be specified on the command line without a value, in + * which case `fallback` is used. When the option is followed by a value that + * value is used as normal. The option itself must still appear on the command + * line unless combined with `.optional()` or `.withDefault()`. + * + * **Example** + * + * ```ts + * import * as Options from "@effect/cli/Options" + * + * // --log-level → "info" (fallback) + * // --log-level debug → "debug" (supplied value) + * // (flag absent) → error: Expected to find option: '--log-level' + * const logLevel = Options.text("log-level").pipe(Options.withOptionalValue("info")) + * ``` + * + * @since 1.0.0 + * @category combinators + */ +export const withOptionalValue: { + (fallback: A): (self: Options) => Options + (self: Options, fallback: A): Options +} = InternalOptions.withOptionalValue + /** * @since 1.0.0 * @category combinators diff --git a/packages/cli/src/internal/options.ts b/packages/cli/src/internal/options.ts index d6f5207f006..f9ed1db3296 100644 --- a/packages/cli/src/internal/options.ts +++ b/packages/cli/src/internal/options.ts @@ -88,6 +88,7 @@ export interface Single extends readonly primitiveType: Primitive.Primitive readonly description: HelpDoc.HelpDoc readonly pseudoName: Option.Option + readonly optionalValueDefault: Option.Option }> {} @@ -589,7 +590,8 @@ export const withAlias = dual< aliases, single.primitiveType, single.description, - single.pseudoName + single.pseudoName, + single.optionalValueDefault ) as Single })) @@ -645,7 +647,8 @@ export const withDescription = dual< single.aliases, single.primitiveType, description, - single.pseudoName + single.pseudoName, + single.optionalValueDefault ) as Single })) @@ -660,7 +663,23 @@ export const withPseudoName = dual< single.aliases, single.primitiveType, single.description, - Option.some(pseudoName) + Option.some(pseudoName), + single.optionalValueDefault + ) as Single)) + +/** @internal */ +export const withOptionalValue = dual< + (fallback: A) => (self: Options.Options) => Options.Options, + (self: Options.Options, fallback: A) => Options.Options +>(2, (self, fallback) => + modifySingle(self as Instruction, (single) => + makeSingle( + single.name, + single.aliases, + single.primitiveType, + single.description, + single.pseudoName, + Option.some(fallback as unknown) ) as Single)) /** @internal */ @@ -926,7 +945,10 @@ const getUsageInternal = (self: Instruction): Usage.Usage => { InternalPrimitive.getChoices(self.primitiveType), () => Option.some(self.placeholder) ) - return InternalUsage.named(getNames(self), acceptedValues) + const displayValues = Option.isSome(self.optionalValueDefault) + ? Option.map(acceptedValues, (v) => `[${v}]`) + : acceptedValues + return InternalUsage.named(getNames(self), displayValues) } case "KeyValueMap": { return getUsageInternal(self.argumentOption as Instruction) @@ -1022,7 +1044,8 @@ const makeSingle = ( aliases: ReadonlyArray, primitiveType: Primitive.Primitive, description: HelpDoc.HelpDoc = InternalHelpDoc.empty, - pseudoName: Option.Option = Option.none() + pseudoName: Option.Option = Option.none(), + optionalValueDefault: Option.Option = Option.none() ): Options.Options => { const op = Object.create(proto) op._tag = "Single" @@ -1033,6 +1056,7 @@ const makeSingle = ( op.primitiveType = primitiveType op.description = description op.pseudoName = pseudoName + op.optionalValueDefault = optionalValueDefault return op } @@ -1202,6 +1226,9 @@ const parseInternal = ( const tail = Arr.tailNonEmpty(singleNames) if (Arr.isEmptyReadonlyArray(tail)) { if (Arr.isEmptyReadonlyArray(head)) { + if (Option.isSome(self.optionalValueDefault)) { + return Effect.succeed(self.optionalValueDefault.value) + } return InternalPrimitive.validate(self.primitiveType, Option.none(), config).pipe( Effect.mapError((e) => InternalValidationError.invalidValue(InternalHelpDoc.p(e))) ) @@ -1761,6 +1788,10 @@ const parseCommandLine = ( } return Arr.matchLeft(tail, { onEmpty: () => { + if (Option.isSome(self.optionalValueDefault)) { + const parsed = Option.some({ name: head, values: Arr.empty() }) + return Effect.succeed({ parsed, leftover: tail }) + } const error = InternalHelpDoc.p( `Expected a value following option: '${self.fullName}'` ) @@ -1856,6 +1887,11 @@ const parseCommandLine = ( } if (afterOption.length === 0) { + if (Option.isSome(self.optionalValueDefault)) { + const parsed = Option.some({ name: optionName, values: Arr.empty() }) + const leftover = Arr.appendAll(beforeOption, afterOption) + return Effect.succeed({ parsed, leftover }) + } const error = InternalHelpDoc.p( `Expected a value following option: '${self.fullName}'` ) diff --git a/packages/cli/test/Options.test.ts b/packages/cli/test/Options.test.ts index e31383a0481..820574f5dd2 100644 --- a/packages/cli/test/Options.test.ts +++ b/packages/cli/test/Options.test.ts @@ -785,4 +785,117 @@ describe("Options", () => { expect(result).toEqual([["positional"], HashMap.make(["key1", "value1"], ["key2", "value2"])]) }).pipe(runEffect)) }) + + describe("withOptionalValue", () => { + const level = Options.text("level").pipe(Options.withOptionalValue("info")) + + it("uses the supplied value when the flag is followed by a value", () => + Effect.gen(function*() { + const result = yield* process(level, ["--level", "debug"], CliConfig.defaultConfig) + expect(result).toEqual([[], "debug"]) + }).pipe(runEffect)) + + it("uses the fallback when the flag is present with no value", () => + Effect.gen(function*() { + const result = yield* process(level, ["--level"], CliConfig.defaultConfig) + expect(result).toEqual([[], "info"]) + }).pipe(runEffect)) + + it("fails when the flag is absent", () => + Effect.gen(function*() { + const result = yield* Effect.flip(process(level, [], CliConfig.defaultConfig)) + expect(result).toEqual(ValidationError.missingValue(HelpDoc.p("Expected to find option: '--level'"))) + }).pipe(runEffect)) + + it("uses the fallback when the flag appears after other args with no trailing value", () => + Effect.gen(function*() { + const options = Options.all([Options.text("host"), level]) + const result = yield* process( + options, + ["--host", "localhost", "--level"], + CliConfig.defaultConfig + ) + expect(result).toEqual([[], ["localhost", "info"]]) + }).pipe(runEffect)) + + it("uses the supplied value when the flag appears after other args", () => + Effect.gen(function*() { + const options = Options.all([Options.text("host"), level]) + const result = yield* process( + options, + ["--host", "localhost", "--level", "warn"], + CliConfig.defaultConfig + ) + expect(result).toEqual([[], ["localhost", "warn"]]) + }).pipe(runEffect)) + + it("composes with optional() — flag present no value → Some(fallback), absent → None", () => + Effect.gen(function*() { + const levelOpt = Options.optional(level) + const result1 = yield* process(levelOpt, ["--level"], CliConfig.defaultConfig) + const result2 = yield* process(levelOpt, [], CliConfig.defaultConfig) + const result3 = yield* process(levelOpt, ["--level", "warn"], CliConfig.defaultConfig) + expect(result1).toEqual([[], Option.some("info")]) + expect(result2).toEqual([[], Option.none()]) + expect(result3).toEqual([[], Option.some("warn")]) + }).pipe(runEffect)) + + it("composes with withDefault() — flag present no value → fallback, absent → withDefault value", () => + Effect.gen(function*() { + const levelDefault = level.pipe(Options.withDefault("warn")) + const result1 = yield* process(levelDefault, ["--level"], CliConfig.defaultConfig) + const result2 = yield* process(levelDefault, [], CliConfig.defaultConfig) + expect(result1).toEqual([[], "info"]) + expect(result2).toEqual([[], "warn"]) + }).pipe(runEffect)) + + it("preserves aliases applied before withOptionalValue", () => + Effect.gen(function*() { + const levelAliased = Options.text("level").pipe( + Options.withAlias("l"), + Options.withOptionalValue("info") + ) + const result = yield* process(levelAliased, ["-l"], CliConfig.defaultConfig) + expect(result).toEqual([[], "info"]) + }).pipe(runEffect)) + + it("preserves aliases applied after withOptionalValue", () => + Effect.gen(function*() { + const levelAliased = Options.text("level").pipe( + Options.withOptionalValue("info"), + Options.withAlias("l") + ) + const result = yield* process(levelAliased, ["-l"], CliConfig.defaultConfig) + expect(result).toEqual([[], "info"]) + }).pipe(runEffect)) + + it("uses --flag=value syntax normally", () => + Effect.gen(function*() { + const result = yield* process(level, ["--level=debug"], CliConfig.defaultConfig) + expect(result).toEqual([[], "debug"]) + }).pipe(runEffect)) + + it("works with choice options (canonical use case from the feature request)", () => + Effect.gen(function*() { + const watch = Options.choice("watch", ["first-failure", "until-done"] as const).pipe( + Options.withOptionalValue("first-failure" as const), + Options.optional + ) + const result1 = yield* process(watch, ["--watch"], CliConfig.defaultConfig) + const result2 = yield* process(watch, ["--watch", "until-done"], CliConfig.defaultConfig) + const result3 = yield* process(watch, [], CliConfig.defaultConfig) + expect(result1).toEqual([[], Option.some("first-failure")]) + expect(result2).toEqual([[], Option.some("until-done")]) + expect(result3).toEqual([[], Option.none()]) + }).pipe(runEffect)) + + it("works with integer options", () => + Effect.gen(function*() { + const count = Options.integer("count").pipe(Options.withOptionalValue(1)) + const result1 = yield* process(count, ["--count"], CliConfig.defaultConfig) + const result2 = yield* process(count, ["--count", "5"], CliConfig.defaultConfig) + expect(result1).toEqual([[], 1]) + expect(result2).toEqual([[], 5]) + }).pipe(runEffect)) + }) }) From 0110d185f9a55f751007b213a7f9331df4279cb8 Mon Sep 17 00:00:00 2001 From: Zelys Date: Thu, 9 Jul 2026 19:18:35 -0500 Subject: [PATCH 2/2] fix(@effect/cli): fall back to withOptionalValue's default before another flag Options.withOptionalValue(fallback) only fell back to the default when the flag was the last token on the command line. Anywhere else, the parser consumed whatever came next as the value, even when that token was itself another flag (--watch --other wrongly consumed --other as watch's value). Add a check for whether the next token looks like another flag before consuming it as a value, excluding negative numbers so integer/float options with a fallback still parse values like -5. Also handle the --flag=value case explicitly so it keeps taking precedence over the new fallback check. Fixes a TS2684 generic-inference error in the choice-option test that hadn't surfaced in CI yet. --- packages/cli/src/internal/options.ts | 13 +++++++++++++ packages/cli/test/Options.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/internal/options.ts b/packages/cli/src/internal/options.ts index f9ed1db3296..367db48d000 100644 --- a/packages/cli/src/internal/options.ts +++ b/packages/cli/src/internal/options.ts @@ -1700,6 +1700,9 @@ interface ParsedCommandLine { const CLUSTERED_REGEX = /^-{1}([^-]{2,}$)/ const FLAG_REGEX = /^(--[^=]+)(?:=(.+))?$/ +const NEGATIVE_NUMBER_REGEX = /^-(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/ + +const looksLikeAnotherFlag = (value: string): boolean => value.startsWith("-") && !NEGATIVE_NUMBER_REGEX.test(value) /** * Normalizes the leading command-line argument by performing the following: @@ -1754,6 +1757,7 @@ const parseCommandLine = ( ): Effect.Effect => { switch (self._tag) { case "Single": { + const usedExplicitEquals = args.length > 0 && FLAG_REGEX.exec(args[0].trim())?.[2] !== undefined return processArgs(args).pipe(Effect.flatMap((args) => Arr.matchLeft(args, { onEmpty: () => { @@ -1798,6 +1802,10 @@ const parseCommandLine = ( return Effect.fail(InternalValidationError.missingValue(error)) }, onNonEmpty: (value, leftover) => { + if (Option.isSome(self.optionalValueDefault) && !usedExplicitEquals && looksLikeAnotherFlag(value)) { + const parsed = Option.some({ name: head, values: Arr.empty() }) + return Effect.succeed({ parsed, leftover: tail }) + } const parsed = Option.some({ name: head, values: Arr.of(value) }) return Effect.succeed({ parsed, leftover }) } @@ -1899,6 +1907,11 @@ const parseCommandLine = ( } const optionValue = afterOption[0] + if (Option.isSome(self.optionalValueDefault) && looksLikeAnotherFlag(optionValue)) { + const parsed = Option.some({ name: optionName, values: Arr.empty() }) + const leftover = Arr.appendAll(beforeOption, afterOption) + return Effect.succeed({ parsed, leftover }) + } const parsed = Option.some({ name: optionName, values: Arr.of(optionValue) }) const leftover = Arr.appendAll(beforeOption, afterOption.slice(1)) return Effect.succeed({ parsed, leftover }) diff --git a/packages/cli/test/Options.test.ts b/packages/cli/test/Options.test.ts index 820574f5dd2..c01cbb75cba 100644 --- a/packages/cli/test/Options.test.ts +++ b/packages/cli/test/Options.test.ts @@ -875,10 +875,16 @@ describe("Options", () => { expect(result).toEqual([[], "debug"]) }).pipe(runEffect)) + it("uses --flag=-value syntax even when the value looks like a flag", () => + Effect.gen(function*() { + const result = yield* process(level, ["--level=-foo"], CliConfig.defaultConfig) + expect(result).toEqual([[], "-foo"]) + }).pipe(runEffect)) + it("works with choice options (canonical use case from the feature request)", () => Effect.gen(function*() { const watch = Options.choice("watch", ["first-failure", "until-done"] as const).pipe( - Options.withOptionalValue("first-failure" as const), + Options.withOptionalValue<"first-failure" | "until-done">("first-failure"), Options.optional ) const result1 = yield* process(watch, ["--watch"], CliConfig.defaultConfig) @@ -894,8 +900,28 @@ describe("Options", () => { const count = Options.integer("count").pipe(Options.withOptionalValue(1)) const result1 = yield* process(count, ["--count"], CliConfig.defaultConfig) const result2 = yield* process(count, ["--count", "5"], CliConfig.defaultConfig) + const result3 = yield* process(count, ["--count", "-5"], CliConfig.defaultConfig) expect(result1).toEqual([[], 1]) expect(result2).toEqual([[], 5]) + expect(result3).toEqual([[], -5]) + }).pipe(runEffect)) + + it("uses the fallback when the flag is followed by another flag", () => + Effect.gen(function*() { + const options = Options.all([level, Options.boolean("watch")]) + const result = yield* process(options, ["--level", "--watch"], CliConfig.defaultConfig) + expect(result).toEqual([[], ["info", true]]) + }).pipe(runEffect)) + + it("uses the fallback when the flag is followed by another flag using its alias", () => + Effect.gen(function*() { + const levelAliased = Options.text("level").pipe( + Options.withOptionalValue("info"), + Options.withAlias("l") + ) + const options = Options.all([levelAliased, Options.boolean("watch")]) + const result = yield* process(options, ["-l", "--watch"], CliConfig.defaultConfig) + expect(result).toEqual([[], ["info", true]]) }).pipe(runEffect)) }) })