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))
+ })
})