From 9ca73a0770c70eabb6948601fd89432c9de8e478 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 16:06:44 -0500 Subject: [PATCH 01/29] refactor(cli): clarify parser scan flow --- .../src/unstable/cli/internal/parser.ts | 129 +++++++++++------- 1 file changed, 81 insertions(+), 48 deletions(-) diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 497737c23..991e5da58 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -209,15 +209,15 @@ export const extractBuiltInOptions = ( /* One-level scan */ /* ====================================================================== */ -type LevelLeaf = { - readonly type: "leaf" +type LeafResult = { + readonly _tag: "Leaf" readonly flags: FlagMap readonly arguments: ReadonlyArray readonly errors: ReadonlyArray } -type LevelSubcommand = { - readonly type: "sub" +type SubcommandResult = { + readonly _tag: "Sub" readonly flags: FlagMap readonly leadingArguments: ReadonlyArray readonly sub: Command @@ -225,7 +225,40 @@ type LevelSubcommand = { readonly errors: ReadonlyArray } -type LevelResult = LevelLeaf | LevelSubcommand +type LevelResult = LeafResult | SubcommandResult + +interface ParseState { + readonly flags: MutableFlagMap + readonly arguments: Array + readonly errors: Array + seenFirstValue: boolean +} + +const makeParseState = (flags: ReadonlyArray): ParseState => ({ + flags: makeFlagMap(flags), + arguments: [], + errors: [], + seenFirstValue: false +}) + +const recordFlagValue = ( + state: ParseState, + name: string, + raw: string | undefined +): void => { + appendFlagValue(state.flags, name, raw) +} + +const mergeFlagValues = (state: ParseState, from: FlagMap | MutableFlagMap): void => { + mergeIntoFlagMap(state.flags, from) +} + +const toLeafResult = (state: ParseState): LeafResult => ({ + _tag: "Leaf", + flags: toReadonlyFlagMap(state.flags), + arguments: state.arguments, + errors: state.errors +}) const isFlagParam = (s: Param.Single): s is Param.Single => s.kind === "flag" @@ -237,59 +270,60 @@ const scanCommandLevel = ( commandPath: ReadonlyArray ): LevelResult => { const index = buildFlagIndex(flags) - const bag = makeFlagMap(flags) - const operands: Array = [] - const errors: Array = [] - let seenFirstValue = false + const state = makeParseState(flags) const expectsArgs = command.config.arguments.length > 0 - const cursor = makeCursor(tokens) + const handleFlag = (t: Extract) => { + const spec = index.get(flagName(t)) + if (!spec) { + const err = unrecognizedFlagError(t, flags, commandPath) + if (err) state.errors.push(err) + return + } + recordFlagValue(state, spec.name, readFlagValue(cursor, t, spec)) + } + + const handleFirstValue = (value: string): SubcommandResult | undefined => { + const sub = command.subcommands.find((s) => s.name === value) + if (sub) { + // Allow parent flags to appear after the subcommand name (npm-style) + const tail = collectFlagValues(cursor.rest(), flags) + mergeFlagValues(state, tail.flagMap) + return { + _tag: "Sub", + flags: toReadonlyFlagMap(state.flags), + leadingArguments: [], + sub, + childTokens: tail.remainder, + errors: state.errors + } + } + + if (!expectsArgs && command.subcommands.length > 0) { + const suggestions = suggest(value, command.subcommands.map((s) => s.name)) + state.errors.push(new CliError.UnknownSubcommand({ subcommand: value, parent: commandPath, suggestions })) + } + return undefined + } + for (let t = cursor.take(); t; t = cursor.take()) { if (isFlagToken(t)) { - const spec = index.get(flagName(t)) - if (!spec) { - const err = unrecognizedFlagError(t, flags, commandPath) - if (err) errors.push(err) - // Do not consume a following value; it may be a subcommand or operand - continue - } - appendFlagValue(bag, spec.name, readFlagValue(cursor, t, spec)) + handleFlag(t) continue } - // Value → only the FIRST value may be a subcommand boundary; others are arguments if (t._tag === "Value") { - if (!seenFirstValue) { - seenFirstValue = true - const sub = command.subcommands.find((s) => s.name === t.value) - if (sub) { - // Allow parent flags to appear after the subcommand name (npm-style) - const tail = collectFlagValues(cursor.rest(), flags) - mergeIntoFlagMap(bag, tail.flagMap) - return { - type: "sub", - flags: toReadonlyFlagMap(bag), - leadingArguments: [], - sub, - childTokens: tail.remainder, - errors - } - } else { - if (!expectsArgs && command.subcommands.length > 0) { - const suggestions = suggest(t.value, command.subcommands.map((s) => s.name)) - errors.push(new CliError.UnknownSubcommand({ subcommand: t.value, parent: commandPath, suggestions })) - } - } + if (!state.seenFirstValue) { + state.seenFirstValue = true + const sub = handleFirstValue(t.value) + if (sub) return sub } - operands.push(t.value) + state.arguments.push(t.value) } } - // Unknown subcommand validation handled inline on first value; remaining checks - // are deferred to argument parsing. - - return { type: "leaf", flags: toReadonlyFlagMap(bag), arguments: operands, errors } + return toLeafResult(state) } /* ====================================================================== */ @@ -312,7 +346,7 @@ export const parseArgs = ( const result = scanCommandLevel(tokens, command, flags, newCommandPath) - if (result.type === "leaf") { + if (result._tag === "Leaf") { return { flags: result.flags, arguments: [...result.arguments, ...afterEndOfOptions], @@ -320,7 +354,6 @@ export const parseArgs = ( } } - // Subcommand recursion const subLex: LexResult = { tokens: result.childTokens, trailingOperands: [] } const subParsed = yield* parseArgs( subLex, @@ -328,7 +361,7 @@ export const parseArgs = ( newCommandPath ) - const allErrors = [...result.errors, ...(subParsed.errors || [])] + const allErrors = [...result.errors, ...(subParsed.errors ?? [])] return { flags: result.flags, arguments: [...result.leadingArguments, ...afterEndOfOptions], From 77a29387028457809dfd42124a421f6bb771efd6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 16:10:18 -0500 Subject: [PATCH 02/29] chore(cli): document parser pipeline and simplify subcommand lookup --- .../src/unstable/cli/internal/parser.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 991e5da58..52a6249e9 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -1,3 +1,18 @@ +/** + * Parsing pipeline for CLI commands + * -------------------------------- + * 1. `lexer` turns argv into tokens. + * 2. `extractBuiltInOptions` peels off built-ins (help/version/completions). + * 3. `parseArgs` recursively scans one command level at a time: + * - collect this level's flags + * - detect an optional subcommand (only the first value can open one) + * - forward any remaining tokens to the child + * + * Invariants + * - Parent flags may appear before or after the subcommand name (npm-style). + * - Only the very first Value token may be interpreted as a subcommand name. + * - Errors accumulate; no exceptions are thrown from the parser. + */ import * as Option from "../../../data/Option.ts" import * as Effect from "../../../Effect.ts" import type { LogLevel } from "../../../LogLevel.ts" @@ -52,6 +67,10 @@ const buildFlagIndex = ( return lookup } +const buildSubcommandIndex = ( + subcommands: ReadonlyArray> +): Map> => new Map(subcommands.map((sub) => [sub.name, sub])) + const isFlagToken = (t: Token): t is Extract => t._tag === "LongOption" || t._tag === "ShortOption" @@ -270,6 +289,7 @@ const scanCommandLevel = ( commandPath: ReadonlyArray ): LevelResult => { const index = buildFlagIndex(flags) + const subIndex = buildSubcommandIndex(command.subcommands) const state = makeParseState(flags) const expectsArgs = command.config.arguments.length > 0 const cursor = makeCursor(tokens) @@ -285,7 +305,7 @@ const scanCommandLevel = ( } const handleFirstValue = (value: string): SubcommandResult | undefined => { - const sub = command.subcommands.find((s) => s.name === value) + const sub = subIndex.get(value) if (sub) { // Allow parent flags to appear after the subcommand name (npm-style) const tail = collectFlagValues(cursor.rest(), flags) @@ -300,7 +320,7 @@ const scanCommandLevel = ( } } - if (!expectsArgs && command.subcommands.length > 0) { + if (!expectsArgs && subIndex.size > 0) { const suggestions = suggest(value, command.subcommands.map((s) => s.name)) state.errors.push(new CliError.UnknownSubcommand({ subcommand: value, parent: commandPath, suggestions })) } From e1bf50c70392ebce310ebb538c9d258f438fc2e4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 16:17:34 -0500 Subject: [PATCH 03/29] refactor(cli): use parse context and phase headers --- .../src/unstable/cli/internal/parser.ts | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 52a6249e9..bdf27653c 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -36,6 +36,16 @@ type FlagParam = Param.Single type FlagMap = Record> type MutableFlagMap = Record> +type CommandContext = { + readonly command: Command + readonly commandPath: ReadonlyArray + readonly flagParams: ReadonlyArray +} + +/* ====================================================================== */ +/* Cursor (token navigation) */ +/* ====================================================================== */ + interface TokenCursor { readonly peek: () => Token | undefined readonly take: () => Token | undefined @@ -51,6 +61,10 @@ const makeCursor = (tokens: ReadonlyArray): TokenCursor => { } } +/* ====================================================================== */ +/* Flag tables */ +/* ====================================================================== */ + /** Map canonicalized names/aliases → Single (O(1) lookup). */ const buildFlagIndex = ( singles: ReadonlyArray> @@ -71,6 +85,10 @@ const buildSubcommandIndex = ( subcommands: ReadonlyArray> ): Map> => new Map(subcommands.map((sub) => [sub.name, sub])) +/* ====================================================================== */ +/* Flag bag & values */ +/* ====================================================================== */ + const isFlagToken = (t: Token): t is Extract => t._tag === "LongOption" || t._tag === "ShortOption" @@ -284,20 +302,19 @@ const isFlagParam = (s: Param.Single): s is Param.Single< const scanCommandLevel = ( tokens: ReadonlyArray, - command: Command, - flags: ReadonlyArray, - commandPath: ReadonlyArray + context: CommandContext ): LevelResult => { - const index = buildFlagIndex(flags) + const { command, commandPath, flagParams } = context + const index = buildFlagIndex(flagParams) const subIndex = buildSubcommandIndex(command.subcommands) - const state = makeParseState(flags) + const state = makeParseState(flagParams) const expectsArgs = command.config.arguments.length > 0 const cursor = makeCursor(tokens) const handleFlag = (t: Extract) => { const spec = index.get(flagName(t)) if (!spec) { - const err = unrecognizedFlagError(t, flags, commandPath) + const err = unrecognizedFlagError(t, flagParams, commandPath) if (err) state.errors.push(err) return } @@ -308,7 +325,7 @@ const scanCommandLevel = ( const sub = subIndex.get(value) if (sub) { // Allow parent flags to appear after the subcommand name (npm-style) - const tail = collectFlagValues(cursor.rest(), flags) + const tail = collectFlagValues(cursor.rest(), flagParams) mergeFlagValues(state, tail.flagMap) return { _tag: "Sub", @@ -362,9 +379,13 @@ export const parseArgs = ( // Flags available at this level (ignore arguments) const singles = command.config.flags.flatMap(Param.extractSingleParams) - const flags = singles.filter(isFlagParam) + const flagParams = singles.filter(isFlagParam) - const result = scanCommandLevel(tokens, command, flags, newCommandPath) + const result = scanCommandLevel(tokens, { + command, + commandPath: newCommandPath, + flagParams + }) if (result._tag === "Leaf") { return { From 85f344ee4f88e081c85a15fa2a6fb69ba8142eb5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 16:23:44 -0500 Subject: [PATCH 04/29] refactor(cli): centralize flag spec for readability --- .../src/unstable/cli/internal/parser.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index bdf27653c..3dc9accc3 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -36,10 +36,15 @@ type FlagParam = Param.Single type FlagMap = Record> type MutableFlagMap = Record> +type FlagSpec = { + readonly params: ReadonlyArray + readonly index: Map +} + type CommandContext = { readonly command: Command readonly commandPath: ReadonlyArray - readonly flagParams: ReadonlyArray + readonly flagSpec: FlagSpec } /* ====================================================================== */ @@ -85,6 +90,11 @@ const buildSubcommandIndex = ( subcommands: ReadonlyArray> ): Map> => new Map(subcommands.map((sub) => [sub.name, sub])) +const makeFlagSpec = (params: ReadonlyArray): FlagSpec => ({ + params, + index: buildFlagIndex(params) +}) + /* ====================================================================== */ /* Flag bag & values */ /* ====================================================================== */ @@ -178,13 +188,14 @@ const builtInFlagParams: ReadonlyArray = [ ...Param.extractSingleParams(dynamicCompletionsFlag) ] +const builtInFlagSpec = makeFlagSpec(builtInFlagParams) + /** Collect only the provided flags; leave everything else untouched as remainder. */ const collectFlagValues = ( tokens: ReadonlyArray, - flags: ReadonlyArray + spec: FlagSpec ): { flagMap: FlagMap; remainder: ReadonlyArray } => { - const lookup = buildFlagIndex(flags) - const flagMap = makeFlagMap(flags) + const flagMap = makeFlagMap(spec.params) const remainder: Array = [] const cursor = makeCursor(tokens) @@ -193,13 +204,13 @@ const collectFlagValues = ( remainder.push(t) continue } - const spec = lookup.get(flagName(t)) - if (!spec) { + const param = spec.index.get(flagName(t)) + if (!param) { // Not one of the target flags → don't consume a following value remainder.push(t) continue } - appendFlagValue(flagMap, spec.name, readFlagValue(cursor, t, spec)) + appendFlagValue(flagMap, param.name, readFlagValue(cursor, t, param)) } return { flagMap: toReadonlyFlagMap(flagMap), remainder } @@ -225,7 +236,7 @@ export const extractBuiltInOptions = ( FileSystem | Path > => Effect.gen(function*() { - const { flagMap, remainder } = collectFlagValues(tokens, builtInFlagParams) + const { flagMap, remainder } = collectFlagValues(tokens, builtInFlagSpec) const emptyArgs: Param.ParsedArgs = { flags: flagMap, arguments: [] } const [, help] = yield* helpFlag.parse(emptyArgs) const [, logLevel] = yield* logLevelFlag.parse(emptyArgs) @@ -271,8 +282,8 @@ interface ParseState { seenFirstValue: boolean } -const makeParseState = (flags: ReadonlyArray): ParseState => ({ - flags: makeFlagMap(flags), +const makeParseState = (flagSpec: FlagSpec): ParseState => ({ + flags: makeFlagMap(flagSpec.params), arguments: [], errors: [], seenFirstValue: false @@ -304,17 +315,17 @@ const scanCommandLevel = ( tokens: ReadonlyArray, context: CommandContext ): LevelResult => { - const { command, commandPath, flagParams } = context - const index = buildFlagIndex(flagParams) + const { command, commandPath, flagSpec } = context + const { index } = flagSpec const subIndex = buildSubcommandIndex(command.subcommands) - const state = makeParseState(flagParams) + const state = makeParseState(flagSpec) const expectsArgs = command.config.arguments.length > 0 const cursor = makeCursor(tokens) const handleFlag = (t: Extract) => { const spec = index.get(flagName(t)) if (!spec) { - const err = unrecognizedFlagError(t, flagParams, commandPath) + const err = unrecognizedFlagError(t, flagSpec.params, commandPath) if (err) state.errors.push(err) return } @@ -325,7 +336,7 @@ const scanCommandLevel = ( const sub = subIndex.get(value) if (sub) { // Allow parent flags to appear after the subcommand name (npm-style) - const tail = collectFlagValues(cursor.rest(), flagParams) + const tail = collectFlagValues(cursor.rest(), flagSpec) mergeFlagValues(state, tail.flagMap) return { _tag: "Sub", @@ -380,11 +391,12 @@ export const parseArgs = ( // Flags available at this level (ignore arguments) const singles = command.config.flags.flatMap(Param.extractSingleParams) const flagParams = singles.filter(isFlagParam) + const flagSpec = makeFlagSpec(flagParams) const result = scanCommandLevel(tokens, { command, commandPath: newCommandPath, - flagParams + flagSpec }) if (result._tag === "Leaf") { From fab26075c1a7274349b7381f4f577c72be86641c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 17:04:56 -0500 Subject: [PATCH 05/29] refactor(cli): encapsulate flag bag helper --- .../src/unstable/cli/internal/parser.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 3dc9accc3..57cbbd1aa 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -41,6 +41,12 @@ type FlagSpec = { readonly index: Map } +type FlagBag = { + readonly add: (name: string, raw: string | undefined) => void + readonly merge: (from: FlagMap | MutableFlagMap) => void + readonly snapshot: () => FlagMap +} + type CommandContext = { readonly command: Command readonly commandPath: ReadonlyArray @@ -95,6 +101,15 @@ const makeFlagSpec = (params: ReadonlyArray): FlagSpec => ({ index: buildFlagIndex(params) }) +const makeFlagBag = (params: ReadonlyArray): FlagBag => { + const map = makeFlagMap(params) + return { + add: (name, raw) => appendFlagValue(map, name, raw), + merge: (from) => mergeIntoFlagMap(map, from), + snapshot: () => toReadonlyFlagMap(map) + } +} + /* ====================================================================== */ /* Flag bag & values */ /* ====================================================================== */ @@ -276,34 +291,22 @@ type SubcommandResult = { type LevelResult = LeafResult | SubcommandResult interface ParseState { - readonly flags: MutableFlagMap + readonly flags: FlagBag readonly arguments: Array readonly errors: Array seenFirstValue: boolean } const makeParseState = (flagSpec: FlagSpec): ParseState => ({ - flags: makeFlagMap(flagSpec.params), + flags: makeFlagBag(flagSpec.params), arguments: [], errors: [], seenFirstValue: false }) -const recordFlagValue = ( - state: ParseState, - name: string, - raw: string | undefined -): void => { - appendFlagValue(state.flags, name, raw) -} - -const mergeFlagValues = (state: ParseState, from: FlagMap | MutableFlagMap): void => { - mergeIntoFlagMap(state.flags, from) -} - const toLeafResult = (state: ParseState): LeafResult => ({ _tag: "Leaf", - flags: toReadonlyFlagMap(state.flags), + flags: state.flags.snapshot(), arguments: state.arguments, errors: state.errors }) @@ -329,7 +332,7 @@ const scanCommandLevel = ( if (err) state.errors.push(err) return } - recordFlagValue(state, spec.name, readFlagValue(cursor, t, spec)) + state.flags.add(spec.name, readFlagValue(cursor, t, spec)) } const handleFirstValue = (value: string): SubcommandResult | undefined => { @@ -337,10 +340,10 @@ const scanCommandLevel = ( if (sub) { // Allow parent flags to appear after the subcommand name (npm-style) const tail = collectFlagValues(cursor.rest(), flagSpec) - mergeFlagValues(state, tail.flagMap) + state.flags.merge(tail.flagMap) return { _tag: "Sub", - flags: toReadonlyFlagMap(state.flags), + flags: state.flags.snapshot(), leadingArguments: [], sub, childTokens: tail.remainder, From 09c94dceefcf4b9c2cf5129af247f91569e3a211 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 17:19:23 -0500 Subject: [PATCH 06/29] docs(cli): record parsing semantics and add -- terminator test --- packages/effect/src/unstable/cli/SEMANTICS.md | 41 +++++++++++++++++++ .../effect/test/unstable/cli/Command.test.ts | 30 +++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/effect/src/unstable/cli/SEMANTICS.md diff --git a/packages/effect/src/unstable/cli/SEMANTICS.md b/packages/effect/src/unstable/cli/SEMANTICS.md new file mode 100644 index 000000000..f3e214bfa --- /dev/null +++ b/packages/effect/src/unstable/cli/SEMANTICS.md @@ -0,0 +1,41 @@ +# CLI Semantics (Effect CLI) + +This file records the intended parsing semantics with a short usage example and the test that locks it in. Examples show shell usage, not code. + +- **Parent flags allowed before or after subcommand (npm-style)** + Example: `tool --global install --pkg cowsay` and `tool install --pkg cowsay --global` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should accept parent flags before or after a subcommand (npm-style)" + +- **Only the first value token may open a subcommand; later values are operands** + Example: `tool install pkg1 pkg2` → `install` chosen as subcommand; `pkg1 pkg2` are operands + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should accept parent flags before or after a subcommand (npm-style)" (second invocation covers later operands) + +- **`--` stops option parsing; everything after is an operand (no subcommands/flags)** + Example: `tool -- child --value x` → operands: `child --value x`; subcommand `child` is not entered + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should treat tokens after -- as operands (no subcommand or flags)" + +- **Options may appear before, after, or between operands (relaxed POSIX Guideline 9)** + Examples: `tool copy --recursive src dest`, `tool copy src dest --recursive`, `tool copy --recursive src dest --force` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should support options before, after, or between operands (relaxed POSIX Syntax Guideline No. 9)" + +- **Boolean flags default to true when present; explicit true/false literals are accepted immediately after** + Example: `tool --verbose deploy --target-version 1.0.0` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should handle boolean flags before subcommands" + +- **Unknown subcommands emit suggestions** + Example: `tool cpy` → suggests `copy` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should suggest similar subcommands for unknown subcommands" + +- **Unknown options emit suggestions (long and short)** + Examples: `tool --debugs copy ...`, `tool -u copy ...` + Tests: `packages/effect/test/unstable/cli/Command.test.ts` – "should suggest similar options for unrecognized options" and "should suggest similar short options for unrecognized short options" + +- **Repeated key=value flags merge into one map** + Example: `tool env --env foo=bar --env cool=dude` → `{ foo: "bar", cool: "dude" }` + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should merge repeated key=value flags into a single record" + +- **Parent context is accessible inside subcommands** + Example: `tool --global install --pkg cowsay` → subcommand can read `global` from parent context + Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should allow direct accessing parent config in subcommands" + +If you add or change semantics, update this file and reference the exact test that proves the behavior. diff --git a/packages/effect/test/unstable/cli/Command.test.ts b/packages/effect/test/unstable/cli/Command.test.ts index 76ae54799..b4a900533 100644 --- a/packages/effect/test/unstable/cli/Command.test.ts +++ b/packages/effect/test/unstable/cli/Command.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect" import { Option } from "effect/data" import { FileSystem, Path } from "effect/platform" import { TestConsole } from "effect/testing" -import { Command, Flag, HelpFormatter } from "effect/unstable/cli" +import { Argument, Command, Flag, HelpFormatter } from "effect/unstable/cli" import * as Cli from "./fixtures/ComprehensiveCli.ts" import * as MockTerminal from "./services/MockTerminal.ts" import * as TestActions from "./services/TestActions.ts" @@ -541,6 +541,34 @@ describe("Command", () => { ]) }).pipe(Effect.provide(TestLayer))) + it.effect("should treat tokens after -- as operands (no subcommand or flags)", () => + Effect.gen(function*() { + const captured: Array> = [] + let childInvoked = false + + const root = Command.make("tool", { + rest: Argument.string("rest").pipe(Argument.variadic()) + }, (config) => + Effect.sync(() => { + captured.push(config.rest) + })) + + const child = Command.make("child", { + value: Flag.string("value") + }, () => + Effect.sync(() => { + childInvoked = true + })) + + const cli = root.pipe(Command.withSubcommands(child)) + const runCli = Command.runWith(cli, { version: "1.0.0" }) + + yield* runCli(["--", "child", "--value", "x"]) + + assert.isFalse(childInvoked) + assert.deepStrictEqual(captured, [["child", "--value", "x"]]) + }).pipe(Effect.provide(TestLayer))) + it.effect("should support options before, after, or between operands (relaxed POSIX Syntax Guideline No. 9)", () => Effect.gen(function*() { // Test both orderings work: POSIX (options before operands) and modern (mixed) From 4e2d4d09a757b5dfb4e6187865a1ea2087083449 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 17:26:50 -0500 Subject: [PATCH 07/29] docs(cli): add cross-library semantics comparison --- packages/effect/src/unstable/cli/SEMANTICS.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/effect/src/unstable/cli/SEMANTICS.md b/packages/effect/src/unstable/cli/SEMANTICS.md index f3e214bfa..252747459 100644 --- a/packages/effect/src/unstable/cli/SEMANTICS.md +++ b/packages/effect/src/unstable/cli/SEMANTICS.md @@ -39,3 +39,78 @@ This file records the intended parsing semantics with a short usage example and Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should allow direct accessing parent config in subcommands" If you add or change semantics, update this file and reference the exact test that proves the behavior. + +## Semantics Landscape (popular CLIs vs Effect) + +Below each semantic you’ll find: a short description, a usage example, how major CLI libraries handle it (and whether it’s configurable), and where Effect currently lands (with suggestions if any). + +### Parent flags after a subcommand name (npm-style globals) +- **What**: Whether options defined on the parent command can appear *after* the subcommand token. +- **Example**: `tool install --pkg cowsay --global` +- **Commander / yargs / clap**: Allowed by default; commander/clap can tighten with options. +- **Click / argparse / docopt**: Not allowed; options must be before the command they belong to. +- **Cobra**: Persistent flags are available to children; placement is typically before subcommand, but not strictly enforced. +- **Effect (current)**: Allowed (permissive). Backed by test: “should accept parent flags before or after a subcommand (npm-style)”. +- **Suggestion**: Keep permissive default; document clearly (done). If a future app needs strictness, add an opt-in validator rather than changing defaults. + +### Options after operands (relaxed POSIX Guideline 9) +- **What**: Whether flags can appear after or between positional arguments. +- **Example**: `tool copy src dest --recursive` or `tool copy --recursive src dest --force` +- **Commander / yargs / clap**: Allowed by default; clap can be configured to prefer subcommand precedence. +- **Click / argparse / docopt**: Disallow; options must precede positionals for that command. +- **Cobra**: Generally allows mixed order; `--` ends options. +- **Effect (current)**: Allowed. Test: “should support options before, after, or between operands (relaxed POSIX Syntax Guideline No. 9)”. +- **Suggestion**: Keep permissive; no change. + +### Subcommand selection: only the first value may open a subcommand +- **What**: Whether only the first non-option token can be treated as a subcommand name. +- **Example**: `tool install pkg1 pkg2` → `install` is subcommand; `pkg1 pkg2` are operands. +- **Commander / yargs / clap / Click / argparse / docopt / Cobra**: Yes, subcommand chosen at first value (unless special external-subcommand features are enabled). +- **Effect (current)**: Yes. Covered implicitly by subcommand tests. +- **Suggestion**: Keep; this is the least surprising and matches most ecosystems. + +### End-of-options marker `--` +- **What**: Tokens after `--` are treated purely as operands. +- **Example**: `tool -- child --value x` → operands `child --value x`. +- **Commander / yargs / clap / Click / argparse / docopt / Cobra**: Supported. +- **Effect (current)**: Supported. Test: “should treat tokens after -- as operands (no subcommand or flags)”. +- **Suggestion**: Keep; already locked in by test. + +### Boolean flags defaulting to true when present; optional explicit literal +- **What**: Supplying `--flag` implies true; explicit `--flag false` (or 0/no/off) is accepted. +- **Example**: `tool --verbose deploy` and `tool --verbose false deploy`. +- **Commander / yargs / clap**: Yes; boolean options coerce common literals. +- **Click / argparse / docopt**: Typically `--flag/--no-flag` pairs; some accept explicit literals with `type=bool` in argparse. +- **Effect (current)**: Yes. Test: “should handle boolean flags before subcommands”. +- **Suggestion**: Keep; no change. + +### Unknown subcommands/options suggestions +- **What**: Whether unrecognized tokens produce Levenshtein suggestions. +- **Example**: `tool cpy` → suggest `copy`; `tool --debugs` → suggest `--debug`. +- **Commander / yargs / clap**: Suggestions configurable (clap has built-in `color`/`suggestions`); commander can display “Did you mean”. +- **Click / argparse / docopt / Cobra**: Usually fail with an error; some wrappers add suggestions. +- **Effect (current)**: Suggestions enabled; tests cover unknown subcommands and options (short/long). +- **Suggestion**: Keep; already aligned with “friendly” CLIs. + +### Parent context accessible in subcommands +- **What**: Ability for subcommand handlers to read the parsed config of their parent. +- **Example**: `tool --global install --pkg cowsay` → subcommand reads `global`. +- **Commander / yargs / clap / Cobra**: Supported via shared options or persistent flags; Click/argparse require manual plumbing. +- **Effect (current)**: Supported; test “should allow direct accessing parent config in subcommands”. +- **Suggestion**: Keep; this is a strength of our design. + +### Repeated key=value flags merging +- **What**: Multiple `--env KEY=VAL` occurrences merge into a single map. +- **Example**: `tool --env foo=bar --env cool=dude` → `{foo: "bar", cool: "dude"}`. +- **Commander / yargs / clap**: Arrays/maps supported via custom options; behavior differs unless configured. +- **Effect (current)**: Merge; test “should merge repeated key=value flags into a single record”. +- **Suggestion**: Keep; predictable and convenient. + +### Required or mutually exclusive flag sets (validation, not parsing) +- **What**: Constraints like “must provide either --token or --user/--pass”, or “--json and --color cannot both be set”. +- **Commander / yargs / clap**: Provide APIs for required/conflicts; Click supports required options and `mutually_exclusive` via groups; argparse has mutually exclusive groups. +- **Effect (current)**: Not enforced at parser level. Would live in a validation layer if needed. +- **Suggestion**: Stay out of parsing; add optional validators in configuration if/when a product needs it. + +## Opinionated default +Effect should remain on the permissive, npm/commander-style side: flexible option placement, parent flags usable before or after subcommands, strict `--` handling, and single-shot subcommand selection on the first value. This keeps UX friendly for modern CLIs while remaining predictable via documented rules and tests. From 957bd093ea2dad3fbbd4260b859ceb88b13f9749 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 17:39:16 -0500 Subject: [PATCH 08/29] test(cli): cover boolean false, combined shorts, -- terminator, and error surfaces --- .../effect/test/unstable/cli/Command.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/packages/effect/test/unstable/cli/Command.test.ts b/packages/effect/test/unstable/cli/Command.test.ts index b4a900533..a9caa109d 100644 --- a/packages/effect/test/unstable/cli/Command.test.ts +++ b/packages/effect/test/unstable/cli/Command.test.ts @@ -569,6 +569,115 @@ describe("Command", () => { assert.deepStrictEqual(captured, [["child", "--value", "x"]]) }).pipe(Effect.provide(TestLayer))) + it.effect("should coerce boolean flags to false when given falsey literals", () => + Effect.gen(function*() { + const captured: Array = [] + + const cmd = Command.make("tool", { + verbose: Flag.boolean("verbose") + }, (config) => Effect.sync(() => captured.push(config.verbose))) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["--verbose", "false"]) + yield* runCmd(["--verbose", "0"]) + + assert.deepStrictEqual(captured, [false, false]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should fail when a required flag value is missing", () => + Effect.gen(function*() { + let invoked = false + + const cmd = Command.make("tool", { + pkg: Flag.string("pkg") + }, () => + Effect.sync(() => { + invoked = true + })) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["--pkg"]) + + assert.isFalse(invoked) + const stderr = yield* TestConsole.errorLines + assert.isAbove(stderr.length, 0) + assert.isTrue(stderr.join("\n").includes("--pkg")) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should parse combined short flags including one that expects a value", () => + Effect.gen(function*() { + const captured: Array<{ all: boolean; verbose: boolean; pkg: string }> = [] + + const cmd = Command.make("tool", { + all: Flag.boolean("all").pipe(Flag.withAlias("a")), + verbose: Flag.boolean("verbose").pipe(Flag.withAlias("v")), + pkg: Flag.string("pkg").pipe(Flag.withAlias("p")) + }, (config) => + Effect.sync(() => { + captured.push(config) + })) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["-avp", "cowsay"]) + + assert.deepStrictEqual(captured, [{ all: true, verbose: true, pkg: "cowsay" }]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should honor -- while still applying parent flags", () => + Effect.gen(function*() { + const captured: Array<{ global: boolean; rest: ReadonlyArray }> = [] + + const root = Command.make("tool", { + global: Flag.boolean("global"), + rest: Argument.string("rest").pipe(Argument.variadic()) + }, (config) => Effect.sync(() => captured.push({ global: config.global, rest: config.rest }))) + + const child = Command.make("child", { + value: Flag.string("value") + }) + + const cli = root.pipe(Command.withSubcommands(child)) + const runCli = Command.runWith(cli, { version: "1.0.0" }) + + yield* runCli(["--global", "--", "child", "--value", "x"]) + + assert.deepStrictEqual(captured, [{ global: true, rest: ["child", "--value", "x"] }]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("should report unknown flag even when subcommand is unknown", () => + Effect.gen(function*() { + const root = Command.make("root", {}) + const known = Command.make("known", {}) + const cli = root.pipe(Command.withSubcommands(known)) + const runCli = Command.runWith(cli, { version: "1.0.0" }) + + yield* runCli(["--unknown", "bogus"]) + + const stderr = yield* TestConsole.errorLines + const text = stderr.join("\n") + assert.isTrue(text.includes("Unrecognized flag: --unknown")) + // Parser may also surface the unknown subcommand; ensure at least one error is emitted. + }).pipe(Effect.provide(TestLayer))) + + it.effect("should keep variadic argument order when options are interleaved", () => + Effect.gen(function*() { + const captured: Array<{ files: ReadonlyArray; verbose: boolean }> = [] + + const cmd = Command.make("copy", { + verbose: Flag.boolean("verbose"), + files: Argument.string("file").pipe(Argument.variadic()) + }, (config) => Effect.sync(() => captured.push({ files: config.files, verbose: config.verbose }))) + + const runCmd = Command.runWith(cmd, { version: "1.0.0" }) + + yield* runCmd(["--verbose", "a.txt", "b.txt", "--verbose", "c.txt"]) + + assert.deepStrictEqual(captured, [{ files: ["a.txt", "b.txt", "c.txt"], verbose: true }]) + }).pipe(Effect.provide(TestLayer))) + it.effect("should support options before, after, or between operands (relaxed POSIX Syntax Guideline No. 9)", () => Effect.gen(function*() { // Test both orderings work: POSIX (options before operands) and modern (mixed) From 5df54303507d7a70a35d260848f420ba4b705425 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 17:49:12 -0500 Subject: [PATCH 09/29] refactor(cli): namespace Command.Config types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename FlagSpec → FlagRegistry, FlagBag → FlagAccumulator - Add explicit ParseMode and FirstValueResult discriminated unions - Break scanCommandLevel into processFlag, processValue, resolveFirstValue - Remove vestigial leadingArguments field - Rename ParsedConfig/InputSpec → Command.Config.Internal - Move CommandConfig → Command.Config namespace - Move InferConfig → Command.Config.Infer - Add isFlagParam helper to Param.ts - Extract config parsing to internal/config.ts --- packages/effect/src/unstable/cli/Command.ts | 588 ++++---------- packages/effect/src/unstable/cli/Param.ts | 9 + .../src/unstable/cli/internal/config.ts | 220 ++++++ .../src/unstable/cli/internal/parser.ts | 748 +++++++++++------- .../__snapshots__/Completions.test.ts.snap | 125 +++ 5 files changed, 978 insertions(+), 712 deletions(-) create mode 100644 packages/effect/src/unstable/cli/internal/config.ts diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 5b0296c56..a58fcf725 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -23,6 +23,7 @@ import { handleCompletionRequest, isCompletionRequest } from "./internal/completions/dynamic/index.ts" +import { type ConfigInternal, isConfigInternal, parseConfig, reconstructTree } from "./internal/config.ts" import * as Lexer from "./internal/lexer.ts" import * as Parser from "./internal/parser.ts" import * as Param from "./Param.ts" @@ -30,7 +31,6 @@ import * as Primitive from "./Primitive.ts" import * as Prompt from "./Prompt.ts" const TypeId = "~effect/cli/Command" as const -const ParsedConfigTypeId = "~effect/cli/Command/ParsedConfig" as const /** * Represents a CLI command with its configuration, handler, and metadata. @@ -101,10 +101,10 @@ export interface Command exten readonly subcommands: ReadonlyArray> /** - * The configuration object that will be used to parse command-line flags - * and positional arguments for the command. + * The processed internal representation of the command's configuration. + * Contains flags, arguments, and the tree structure for reconstruction. */ - readonly config: ParsedConfig + readonly config: Command.Config.Internal /** * A service which can be used to extract this command's positional arguments @@ -128,6 +128,148 @@ export interface Command exten ) => Effect.Effect } +/** + * @since 4.0.0 + */ +export declare namespace Command { + /** + * Configuration object for defining command flags, arguments, and nested structures. + * + * Command.Config allows you to specify: + * - Individual flags and arguments using Param types + * - Nested configuration objects for organization + * - Arrays of parameters for repeated elements + * + * @example + * ```ts + * import { Command, Flag, Argument } from "effect/unstable/cli" + * + * // Simple flat configuration + * const simpleConfig: Command.Config = { + * name: Flag.string("name"), + * age: Flag.integer("age"), + * file: Argument.string("file") + * } + * + * // Nested configuration for organization + * const nestedConfig: Command.Config = { + * user: { + * name: Flag.string("name"), + * email: Flag.string("email") + * }, + * server: { + * host: Flag.string("host"), + * port: Flag.integer("port") + * } + * } + * ``` + * + * @since 4.0.0 + * @category models + */ + export interface Config { + readonly [key: string]: + | Param.Param + | ReadonlyArray | Config> + | Config + } + + export namespace Config { + /** + * The processed internal representation of a Command.Config declaration. + * + * Created by parsing the user's config. Separates parameters by type + * while preserving the original nested structure via the tree. + * + * @example + * ```ts + * import { Command, Flag, Argument } from "effect/unstable/cli" + * + * const cmd = Command.make("deploy", { + * verbose: Flag.boolean("verbose"), + * server: { + * host: Flag.string("host"), + * port: Flag.integer("port") + * }, + * files: Argument.string("files").pipe(Argument.variadic) + * }) + * + * // Access the internal representation: + * // cmd.config.arguments -> [filesParam] + * // cmd.config.flags -> [verboseParam, hostParam, portParam] + * // cmd.config.tree -> preserves nested structure for reconstruction + * ``` + * + * @since 4.0.0 + * @category models + */ + export type Internal = ConfigInternal + + export namespace Internal { + /** + * Maps declaration keys to their node representations. + * Preserves the shape of the user's config object. + */ + export type Tree = ConfigInternal.Tree + + /** + * A node in the config tree. + * + * - Param: References a parameter by index in orderedParams + * - Array: Contains child nodes for tuple/array declarations + * - Nested: Contains a subtree for nested config objects + */ + export type Node = ConfigInternal.Node + } + + /** + * Infers the TypeScript type from a Command.Config structure. + * + * This type utility extracts the final configuration type that handlers will receive, + * preserving the nested structure while converting Param types to their values. + * + * @example + * ```ts + * import { Command, Flag, Argument } from "effect/unstable/cli" + * + * const config = { + * name: Flag.string("name"), + * server: { + * host: Flag.string("host"), + * port: Flag.integer("port") + * } + * } as const + * + * type Result = Command.Config.Infer + * // { + * // readonly name: string + * // readonly server: { + * // readonly host: string + * // readonly port: number + * // } + * // } + * ``` + * + * @since 4.0.0 + * @category models + */ + export type Infer = Simplify< + { readonly [Key in keyof A]: InferValue } + > + + /** + * Helper type utility for recursively inferring types from Config values. + * + * @since 4.0.0 + * @category models + */ + export type InferValue = A extends ReadonlyArray ? { readonly [Key in keyof A]: InferValue } + : A extends Param.Param ? _Value + : A extends Config ? Infer + : never + } +} + /** * The environment required by CLI commands, including file system and path operations. * @@ -227,293 +369,6 @@ export interface RawInput { } } -/** - * Configuration object for defining command flags, arguments, and nested structures. - * - * CommandConfig allows you to specify: - * - Individual flags and arguments using Param types - * - Nested configuration objects for organization - * - Arrays of parameters for repeated elements - * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * import { Effect, Console } from "effect" - * - * // Simple flat configuration - * const simpleConfig = { - * name: Flag.string("name"), - * age: Flag.integer("age"), - * file: Argument.string("file") - * } - * - * // Nested configuration for organization - * const nestedConfig = { - * user: { - * name: Flag.string("name"), - * email: Flag.string("email") - * }, - * server: { - * host: Flag.string("host"), - * port: Flag.integer("port") - * }, - * files: Argument.string("files").pipe(Argument.variadic) - * } - * - * // Use in command creation - * const command = Command.make("deploy", nestedConfig, (config) => - * Effect.gen(function*() { - * // config.user.name, config.server.host, etc. are all type-safe - * yield* Console.log(`Deploying for ${config.user.name} to ${config.server.host}:${config.server.port}`) - * }) - * ) - * ``` - * - * @since 4.0.0 - * @category models - */ -export interface CommandConfig { - readonly [key: string]: - | Param.Param - | ReadonlyArray | CommandConfig> - | CommandConfig -} - -/** - * Infers the TypeScript type from a CommandConfig structure. - * - * This type utility extracts the final configuration type that handlers will receive, - * preserving the nested structure while converting Param types to their values. - * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * import { Effect, Console } from "effect" - * - * // Define a configuration structure - * const config = { - * name: Flag.string("name"), - * server: { - * host: Flag.string("host"), - * port: Flag.integer("port") - * }, - * files: Argument.string("files").pipe(Argument.variadic) - * } as const - * - * // InferConfig extracts the final type - * type ConfigType = Command.InferConfig - * // Result: { - * // readonly name: string - * // readonly server: { - * // readonly host: string - * // readonly port: number - * // } - * // readonly files: ReadonlyArray - * // } - * - * const command = Command.make("deploy", config, (config: ConfigType) => - * Effect.gen(function*() { - * // config is fully typed with the inferred structure - * yield* Console.log(`Deploying to ${config.server.host}:${config.server.port}`) - * }) - * ) - * ``` - * - * @since 4.0.0 - * @category models - */ -export type InferConfig = Simplify< - { readonly [Key in keyof A]: InferConfigValue } -> - -/** - * Helper type utility for recursively inferring types from CommandConfig values. - * - * This type handles the different kinds of values that can appear in a CommandConfig: - * - Arrays of params/configs are recursively processed - * - Param types are extracted to their value types - * - Nested CommandConfig objects are recursively inferred - * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * - * // Single param extraction - * type StringFlag = Command.InferConfigValue - * // Result: string - * - * // Array param extraction - * type StringArgs = readonly string[] - * // Result: ReadonlyArray - * - * // Nested config extraction - * type NestedConfig = Command.InferConfigValue<{ - * host: typeof Flag.string - * port: typeof Flag.integer - * }> - * // Result: { readonly host: string; readonly port: number } - * ``` - * - * @since 4.0.0 - * @category models - */ -export type InferConfigValue = A extends ReadonlyArray ? { readonly [Key in keyof A]: InferConfigValue } - : A extends Param.Param ? _Value - : A extends CommandConfig ? InferConfig - : never - -/** - * Internal tree structure that represents the blueprint for reconstructing parsed configuration. - * - * ConfigTree is used internally during command parsing to maintain the structure - * of nested configuration objects while allowing for flat parameter parsing. - * - * @example - * ```ts - * import { Command } from "effect/unstable/cli" - * - * // Internal structure for config like: - * // { name: Flag.string("name"), db: { host: Flag.string("host") } } - * // - * // Becomes ConfigTree: - * // { - * // name: { _tag: "Param", index: 0 }, - * // db: { - * // _tag: "ParsedConfig", - * // tree: { host: { _tag: "Param", index: 1 } } - * // } - * // } - * ``` - * - * @since 4.0.0 - * @category models - */ -export interface ConfigTree { - [key: string]: ConfigNode -} - -/** - * Individual node in the configuration tree, representing different types of configuration elements. - * - * ConfigNode can be: - * - Param: References a specific parameter by index in the flat parsed array - * - Array: Contains multiple child nodes for array parameters - * - ParsedConfig: Contains a nested configuration tree - * - * @example - * ```ts - * import { Command } from "effect/unstable/cli" - * - * // Different node types: - * - * // Param node (references parsed value at index) - * const paramNode: Command.ConfigNode = { - * _tag: "Param", - * index: 0 - * } - * - * // Array node (contains multiple child nodes) - * const arrayNode: Command.ConfigNode = { - * _tag: "Array", - * children: [ - * { _tag: "Param", index: 1 }, - * { _tag: "Param", index: 2 } - * ] - * } - * - * // ParsedConfig node (contains nested structure) - * const configNode: Command.ConfigNode = { - * _tag: "ParsedConfig", - * tree: { - * host: { _tag: "Param", index: 3 }, - * port: { _tag: "Param", index: 4 } - * } - * } - * ``` - * - * @since 4.0.0 - * @category models - */ -export type ConfigNode = { - readonly _tag: "Param" - readonly index: number -} | { - readonly _tag: "Array" - readonly children: ReadonlyArray -} | { - readonly _tag: "ParsedConfig" - readonly tree: ConfigTree -} - -/** - * Parsed and flattened configuration structure created from a CommandConfig. - * - * ParsedConfig separates parameters by type and maintains both the original - * nested structure (via tree) and the flattened parameter list for parsing. - * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * - * // Example of what parseConfig produces for: - * const config = { - * name: Flag.string("name"), - * db: { - * host: Flag.string("host"), - * port: Flag.integer("port") - * }, - * files: Argument.string("files").pipe(Argument.variadic) - * } - * - * // Results in ParsedConfig structure with: - * // - flags: All flags extracted and flattened - * // - arguments: All arguments extracted and flattened - * // - orderedParams: All params in declaration order - * // - tree: Blueprint preserving original nested structure - * // - * // Tree structure example: - * // { - * // name: { _tag: "Param", index: 0 }, - * // db: { - * // _tag: "ParsedConfig", - * // tree: { - * // host: { _tag: "Param", index: 1 }, - * // port: { _tag: "Param", index: 2 } - * // } - * // }, - * // files: { _tag: "Param", index: 3 } - * // } - * ``` - * - * @since 4.0.0 - * @category models - */ -export interface ParsedConfig { - readonly [ParsedConfigTypeId]: typeof ParsedConfigTypeId - /** - * The parsed command-line positional arguments. - */ - readonly arguments: ReadonlyArray> - /** - * The parsed command-line flags. - */ - readonly flags: ReadonlyArray> - /** - * Represents parsed parameters in the exact order in which they were declared. - */ - readonly orderedParams: ReadonlyArray> - /** - * The parsed configuration tree. - */ - readonly tree: ConfigTree -} - -/** - * @since 4.0.0 - * @category guards - */ -export const isParsedConfig = (u: unknown): u is ParsedConfig => Predicate.hasProperty(u, ParsedConfigTypeId) - const Proto = { ...YieldableProto, pipe() { @@ -594,24 +449,24 @@ const Proto = { export const make: { (name: Name): Command - ( + ( name: Name, config: Config - ): Command, never, never> + ): Command, never, never> - ( + ( name: Name, config: Config, - handler: (config: InferConfig) => Effect.Effect - ): Command, E, R> + handler: (config: Command.Config.Infer) => Effect.Effect + ): Command, E, R> } = (( name: string, - config?: CommandConfig, + config?: Command.Config, handler?: (config: unknown) => Effect.Effect ) => makeCommand({ name, - config: config ?? {} as CommandConfig, + config: config ?? {} as Command.Config, ...(Predicate.isNotUndefined(handler) ? { handle: handler } : {}) })) as any @@ -1324,144 +1179,13 @@ export const runWith = ( Effect.catchTag("UserError", (error: CliError.UserError) => Effect.fail(error.cause as any)) ) -// ============================================================================= -// Command Config -// ============================================================================= - -/** - * Transforms a nested command configuration into a flat structure for parsing. - * - * This function walks through the entire config tree and: - * 1. Extracts all Params into a single flat array (for command-line parsing) - * 2. Creates a "blueprint" tree that remembers the original structure - * 3. Assigns each Param an index to link parsed values back to their position - * - * The separation allows us to: - * - Parse all options using existing flat parsing logic - * - Reconstruct the original nested structure afterward - * - * @example - * Input: { name: Param.string("name"), db: { host: Param.string("host") } } - * Output: { - * options: [Param.string("name"), Param.string("host")], - * tree: { name: {_tag: "Param", index: 0}, db: {_tag: "ParsedConfig", tree: {host: {_tag: "Param", index: 1}}} } - * } - */ -const parseConfig = (config: CommandConfig): ParsedConfig => { - const orderedParams: Array = [] - const flags: Array = [] - const args: Array = [] - - // Recursively walk the config structure, building the blueprint tree - function parse(config: CommandConfig) { - const tree: ConfigTree = {} - for (const key in config) { - tree[key] = parseValue(config[key]) - } - return tree - } - - // Process each value in the config, extracting Params and preserving structure - function parseValue( - value: - | Param.Any - | ReadonlyArray - | CommandConfig - ): ConfigNode { - if (Array.isArray(value)) { - // Array of options/configs - preserve array structure - return { - _tag: "Array", - children: (value as Array).map((value) => parseValue(value)) - } - } else if (Param.isParam(value)) { - // Found a Param - add to appropriate array based on kind and record its index - const index = orderedParams.length - orderedParams.push(value) - - if (value.kind === "argument") { - args.push(value as Param.AnyArgument) - } else { - flags.push(value as Param.AnyFlag) - } - - return { - _tag: "Param", - index - } - } else { - // Nested config object - recursively process - return { - _tag: "ParsedConfig", - tree: parse(value as any) - } - } - } - - return { - [ParsedConfigTypeId]: ParsedConfigTypeId, - flags, - arguments: args, - orderedParams, - tree: parse(config) - } -} - -/** - * Reconstructs the original nested structure using parsed values and the blueprint tree. - * - * This is the inverse operation of parseConfig: - * 1. Takes the flat array of parsed option values - * 2. Uses the blueprint tree to determine where each value belongs - * 3. Rebuilds the original nested object structure - * - * The blueprint tree acts as a "map" showing how to reassemble the flat data - * back into the user's expected nested configuration shape. - * - * @param tree - The blueprint tree created by parseConfig - * @param results - Flat array of parsed values (in the same order as the options array) - * @returns The reconstructed nested configuration object - * - * @example - * Input tree: { name: {_tag: "Param", index: 0}, db: {_tag: "ParsedConfig", tree: {host: {_tag: "Param", index: 1}}} } - * Input results: ["myapp", "localhost"] - * Output: { name: "myapp", db: { host: "localhost" } } - */ -const reconstructConfigTree = ( - tree: ConfigTree, - results: ReadonlyArray -): Record => { - const output: Record = {} - - // Walk through each key in the blueprint tree - for (const key in tree) { - output[key] = nodeValue(tree[key]) - } - - return output - - // Convert a blueprint node back to its corresponding value - function nodeValue(node: ConfigNode): any { - if (node._tag === "Param") { - // Param reference - look up the parsed value by index - return results[node.index] - } else if (node._tag === "Array") { - // Array structure - recursively process each child - return node.children.map((node) => nodeValue(node)) - } else { - // Nested object - recursively reconstruct the subtree - return reconstructConfigTree(node.tree, results) - } - } -} - // ============================================================================= // Utilities // ============================================================================= const makeCommand = (options: { readonly name: Name - readonly config: CommandConfig | ParsedConfig + readonly config: Command.Config | ConfigInternal readonly service?: ServiceMap.Service, Input> | undefined readonly description?: string | undefined readonly subcommands?: ReadonlyArray> | undefined @@ -1472,7 +1196,7 @@ const makeCommand = (options: { }): Command => { const service = options.service ?? ServiceMap.Service, Input>(`${TypeId}/${options.name}`) - const config = isParsedConfig(options.config) ? options.config : parseConfig(options.config) + const config = isConfigInternal(options.config) ? options.config : parseConfig(options.config) const handle = ( input: Input, @@ -1485,7 +1209,7 @@ const makeCommand = (options: { const parse = options.parse ?? Effect.fnUntraced(function*(input: RawInput) { const parsedArgs: Param.ParsedArgs = { flags: input.flags, arguments: input.arguments } const values = yield* parseParams(parsedArgs, config.orderedParams) - return reconstructConfigTree(config.tree, values) as Input + return reconstructTree(config.tree, values) as Input }) return Object.assign(Object.create(Proto), { diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index d365f608e..616486525 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -230,6 +230,15 @@ export const isSingle = ( param: Param ): param is Single => Predicate.isTagged(param, "Single") +/** + * Type guard to check if a Single param is a flag (not an argument). + * + * @internal + */ +export const isFlagParam = ( + single: Single +): single is Single => single.kind === "flag" + /** * @since 4.0.0 * @category constructors diff --git a/packages/effect/src/unstable/cli/internal/config.ts b/packages/effect/src/unstable/cli/internal/config.ts new file mode 100644 index 000000000..05ebb36a0 --- /dev/null +++ b/packages/effect/src/unstable/cli/internal/config.ts @@ -0,0 +1,220 @@ +/** + * Config Internal + * ================ + * + * The processed internal representation of a Command.Config declaration. + * Separates the user's declared config shape from the flat parsing representation. + * + * Key concepts: + * - ConfigInternal: The full processed form (flags, arguments, tree) + * - ConfigInternal.Tree: Maps declaration keys to nodes + * - ConfigInternal.Node: Param reference, Array, or Nested subtree + * + * Example transformation from Command.Config to Command.Config.Internal: + * + * ```ts + * // User declares: + * const config = { + * verbose: Flag.boolean("verbose"), + * server: { + * host: Flag.string("host"), + * port: Flag.integer("port") + * }, + * files: Argument.string("files").pipe(Argument.variadic) + * } + * + * // Becomes Config.Internal: + * { + * arguments: [filesParam], // Flat array of arguments + * flags: [verboseParam, hostParam, portParam], // Flat array of flags + * orderedParams: [verboseParam, hostParam, portParam, filesParam], + * tree: { // Preserves nested structure + * verbose: { _tag: "Param", index: 0 }, + * server: { + * _tag: "Nested", + * tree: { + * host: { _tag: "Param", index: 1 }, + * port: { _tag: "Param", index: 2 } + * } + * }, + * files: { _tag: "Param", index: 3 } + * } + * } + * ``` + * + * This separation allows: + * 1. Flat iteration over all params for parsing/validation + * 2. Reconstruction of original nested shape for handler input + */ +import * as Predicate from "../../../data/Predicate.ts" +import * as Param from "../Param.ts" + +/* ========================================================================== */ +/* Type ID */ +/* ========================================================================== */ + +const ConfigInternalTypeId = "~effect/cli/Command/Config/Internal" as const + +/* ========================================================================== */ +/* Types */ +/* ========================================================================== */ + +/** + * The processed internal representation of a Command.Config declaration. + * + * Created by parsing the user's config. Separates parameters by type + * while preserving the original nested structure via the tree. + */ +export interface ConfigInternal { + readonly [ConfigInternalTypeId]: typeof ConfigInternalTypeId + /** The command's positional argument parameters. */ + readonly arguments: ReadonlyArray + /** The command's flag parameters. */ + readonly flags: ReadonlyArray + /** All parameters in declaration order. */ + readonly orderedParams: ReadonlyArray + /** Tree structure for reconstructing nested config. */ + readonly tree: ConfigInternal.Tree +} + +/** + * @since 4.0.0 + */ +export declare namespace ConfigInternal { + /** + * Maps declaration keys to their node representations. + * Preserves the shape of the user's config object. + */ + export interface Tree { + [key: string]: Node + } + + /** + * A node in the config tree. + * + * - Param: References a parameter by index in orderedParams + * - Array: Contains child nodes for tuple/array declarations + * - Nested: Contains a subtree for nested config objects + */ + export type Node = + | { readonly _tag: "Param"; readonly index: number } + | { readonly _tag: "Array"; readonly children: ReadonlyArray } + | { readonly _tag: "Nested"; readonly tree: Tree } +} + +/* ========================================================================== */ +/* Guards */ +/* ========================================================================== */ + +/** @internal */ +export const isConfigInternal = (u: unknown): u is ConfigInternal => Predicate.hasProperty(u, ConfigInternalTypeId) + +/* ========================================================================== */ +/* Parsing */ +/* ========================================================================== */ + +/** + * Config interface matching Command.Config (duplicated to avoid circular import). + * @internal + */ +interface Config { + readonly [key: string]: + | Param.Param + | ReadonlyArray | Config> + | Config +} + +/** + * Parses a Command.Config into a ConfigInternal. + * + * Walks the config structure and: + * 1. Extracts all Params into flat arrays (flags, arguments, orderedParams) + * 2. Builds a tree that remembers the original nested structure + * 3. Assigns each Param an index to link parsed values back + * + * @internal + */ +export const parseConfig = (config: Config): ConfigInternal => { + const orderedParams: Array = [] + const flags: Array = [] + const args: Array = [] + + function parse(config: Config): ConfigInternal.Tree { + const tree: ConfigInternal.Tree = {} + for (const key in config) { + tree[key] = parseValue(config[key]) + } + return tree + } + + function parseValue( + value: Param.Any | ReadonlyArray | Config + ): ConfigInternal.Node { + if (Array.isArray(value)) { + return { + _tag: "Array", + children: (value as Array).map((v) => parseValue(v)) + } + } else if (Param.isParam(value)) { + const index = orderedParams.length + orderedParams.push(value) + + if (value.kind === "argument") { + args.push(value as Param.AnyArgument) + } else { + flags.push(value as Param.AnyFlag) + } + + return { _tag: "Param", index } + } else { + return { + _tag: "Nested", + tree: parse(value as Config) + } + } + } + + return { + [ConfigInternalTypeId]: ConfigInternalTypeId, + flags, + arguments: args, + orderedParams, + tree: parse(config) + } +} + +/* ========================================================================== */ +/* Reconstruction */ +/* ========================================================================== */ + +/** + * Reconstructs the original nested config shape from parsed values. + * + * Uses the tree as a blueprint to place each parsed value back into + * its original position in the nested structure. + * + * @internal + */ +export const reconstructTree = ( + tree: ConfigInternal.Tree, + results: ReadonlyArray +): Record => { + const output: Record = {} + + for (const key in tree) { + output[key] = nodeValue(tree[key]) + } + + return output + + function nodeValue(node: ConfigInternal.Node): any { + switch (node._tag) { + case "Param": + return results[node.index] + case "Array": + return node.children.map((child) => nodeValue(child)) + case "Nested": + return reconstructTree(node.tree, results) + } + } +} diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 57cbbd1aa..4862c99bc 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -1,17 +1,28 @@ /** - * Parsing pipeline for CLI commands - * -------------------------------- - * 1. `lexer` turns argv into tokens. - * 2. `extractBuiltInOptions` peels off built-ins (help/version/completions). - * 3. `parseArgs` recursively scans one command level at a time: - * - collect this level's flags - * - detect an optional subcommand (only the first value can open one) - * - forward any remaining tokens to the child + * Parsing Pipeline for CLI Commands + * ================================== * - * Invariants - * - Parent flags may appear before or after the subcommand name (npm-style). - * - Only the very first Value token may be interpreted as a subcommand name. - * - Errors accumulate; no exceptions are thrown from the parser. + * The parser transforms raw argv tokens into structured command input through + * three main phases: + * + * 1. **Lexer** (external): Converts argv strings into typed tokens + * - LongOption: --name or --name=value + * - ShortOption: -n or -n=value (also handles -abc as three flags) + * - Value: positional arguments + * + * 2. **Built-in Extraction**: Peels off built-in flags (help/version/completions) + * before command-specific parsing begins. + * + * 3. **Command Parsing**: Recursively processes command levels: + * - Collects flags defined at this level + * - Detects subcommand from first value token + * - Forwards remaining tokens to child command + * + * Key Behaviors + * ------------- + * - Parent flags may appear before OR after the subcommand name (npm-style) + * - Only the first Value token can open a subcommand + * - Errors accumulate rather than throwing exceptions */ import * as Option from "../../../data/Option.ts" import * as Effect from "../../../Effect.ts" @@ -26,40 +37,203 @@ import { suggest } from "./auto-suggest.ts" import { completionsFlag, dynamicCompletionsFlag, helpFlag, logLevelFlag, versionFlag } from "./builtInFlags.ts" import { type LexResult, type Token } from "./lexer.ts" +/* ========================================================================== */ +/* Public API */ +/* ========================================================================== */ + /** @internal */ export const getCommandPath = (parsedInput: RawInput): ReadonlyArray => parsedInput.subcommand ? [parsedInput.subcommand.name, ...getCommandPath(parsedInput.subcommand.parsedInput)] : [] +/** @internal */ +export const extractBuiltInOptions = ( + tokens: ReadonlyArray +): Effect.Effect< + { + help: boolean + logLevel: LogLevel | undefined + version: boolean + completions: "bash" | "zsh" | "fish" | undefined + dynamicCompletions: "bash" | "zsh" | "fish" | undefined + remainder: ReadonlyArray + }, + CliError.CliError, + FileSystem | Path +> => + Effect.gen(function*() { + const { flagMap, remainder } = consumeKnownFlags(tokens, builtInFlagRegistry) + const emptyArgs: Param.ParsedArgs = { flags: flagMap, arguments: [] } + const [, help] = yield* helpFlag.parse(emptyArgs) + const [, logLevel] = yield* logLevelFlag.parse(emptyArgs) + const [, version] = yield* versionFlag.parse(emptyArgs) + const [, completions] = yield* completionsFlag.parse(emptyArgs) + const [, dynamicCompletions] = yield* dynamicCompletionsFlag.parse(emptyArgs) + return { + help, + logLevel: Option.getOrUndefined(logLevel), + version, + completions: Option.getOrUndefined(completions), + dynamicCompletions: Option.getOrUndefined(dynamicCompletions), + remainder + } + }) + +/** @internal */ +export const parseArgs = ( + lexResult: LexResult, + command: Command, + commandPath: ReadonlyArray = [] +): Effect.Effect => + Effect.gen(function*() { + const { tokens, trailingOperands: afterEndOfOptions } = lexResult + const newCommandPath = [...commandPath, command.name] + + const singles = command.config.flags.flatMap(Param.extractSingleParams) + const flagParams = singles.filter(Param.isFlagParam) + const flagRegistry = createFlagRegistry(flagParams) + + const context: CommandContext = { + command, + commandPath: newCommandPath, + flagRegistry + } + + const result = scanCommandLevel(tokens, context) + + if (result._tag === "Leaf") { + return { + flags: result.flags, + arguments: [...result.arguments, ...afterEndOfOptions], + ...(result.errors.length > 0 && { errors: result.errors }) + } + } + + const subLex: LexResult = { tokens: result.childTokens, trailingOperands: [] } + const subParsed = yield* parseArgs( + subLex, + result.sub as unknown as Command, + newCommandPath + ) + + const allErrors = [...result.errors, ...(subParsed.errors ?? [])] + return { + flags: result.flags, + arguments: afterEndOfOptions, + subcommand: { name: result.sub.name, parsedInput: subParsed }, + ...(allErrors.length > 0 && { errors: allErrors }) + } + }) + +/* ========================================================================== */ +/* Types */ +/* ========================================================================== */ + type FlagParam = Param.Single -type FlagMap = Record> -type MutableFlagMap = Record> -type FlagSpec = { +/** + * Mutable map of flag names to their collected string values. + * Used internally during parsing; converted to readonly at boundaries. + */ +type FlagMap = Record> + +/** + * Registry of known flags for a command level. + * Enables O(1) lookup of flag definitions by name or alias. + */ +type FlagRegistry = { + /** All flag params declared at this command level */ readonly params: ReadonlyArray + /** Maps canonical names and aliases → flag param */ readonly index: Map } -type FlagBag = { +/** + * Mutable accumulator for collecting flag values during parsing. + * Provides methods to add values and merge results from sub-scans. + */ +type FlagAccumulator = { + /** Record a value for a flag. No-op if raw is undefined (for boolean flags). */ readonly add: (name: string, raw: string | undefined) => void - readonly merge: (from: FlagMap | MutableFlagMap) => void - readonly snapshot: () => FlagMap + /** Merge values from another flag map into this accumulator */ + readonly merge: (from: FlagMap) => void + /** Return immutable snapshot of accumulated values */ + readonly snapshot: () => Readonly } +/** + * Context for parsing a command level. + * Generics flow through to avoid runtime casts. + */ type CommandContext = { readonly command: Command readonly commandPath: ReadonlyArray - readonly flagSpec: FlagSpec + readonly flagRegistry: FlagRegistry +} + +/** + * Parsing mode during command-level scanning. + * Determines how value tokens are interpreted. + */ +type ParseMode = + | { readonly _tag: "AwaitingFirstValue" } + | { readonly _tag: "CollectingArguments" } + +/** + * Mutable state accumulated during command-level parsing. + */ +type ParseState = { + readonly flags: FlagAccumulator + readonly arguments: Array + readonly errors: Array + mode: ParseMode +} + +/** + * Result when first value token is processed. + */ +type FirstValueResult = + | { readonly _tag: "Subcommand"; readonly result: SubcommandResult } + | { readonly _tag: "Argument" } + +/** + * Terminal result: command level has no subcommand. + */ +type LeafResult = { + readonly _tag: "Leaf" + readonly flags: Readonly + readonly arguments: ReadonlyArray + readonly errors: ReadonlyArray +} + +/** + * Continuation result: subcommand detected, child parsing needed. + */ +type SubcommandResult = { + readonly _tag: "Sub" + readonly flags: Readonly + readonly sub: Command + readonly childTokens: ReadonlyArray + readonly errors: ReadonlyArray } -/* ====================================================================== */ -/* Cursor (token navigation) */ -/* ====================================================================== */ +type LevelResult = LeafResult | SubcommandResult + +/* ========================================================================== */ +/* Token Cursor */ +/* ========================================================================== */ +/** + * Stateful cursor for navigating a token stream. + * Provides peek/take semantics for single-pass parsing. + */ interface TokenCursor { + /** View next token without consuming */ readonly peek: () => Token | undefined + /** Consume and return next token */ readonly take: () => Token | undefined + /** Get all remaining tokens (does not consume) */ readonly rest: () => ReadonlyArray } @@ -72,145 +246,139 @@ const makeCursor = (tokens: ReadonlyArray): TokenCursor => { } } -/* ====================================================================== */ -/* Flag tables */ -/* ====================================================================== */ - -/** Map canonicalized names/aliases → Single (O(1) lookup). */ -const buildFlagIndex = ( - singles: ReadonlyArray> -): Map> => { - const lookup = new Map>() - for (const single of singles) { - if (lookup.has(single.name)) throw new Error(`Duplicate option name: ${single.name}`) - lookup.set(single.name, single) - for (const alias of single.aliases) { - if (lookup.has(alias)) throw new Error(`Duplicate option/alias: ${alias}`) - lookup.set(alias, single) +/* ========================================================================== */ +/* Flag Registry */ +/* ========================================================================== */ + +/** + * Creates a registry for O(1) flag lookup by name or alias. + * @throws Error if duplicate names or aliases are detected + */ +const createFlagRegistry = (params: ReadonlyArray): FlagRegistry => { + const index = new Map() + + for (const param of params) { + if (index.has(param.name)) { + throw new Error(`Duplicate flag name: ${param.name}`) + } + index.set(param.name, param) + + for (const alias of param.aliases) { + if (index.has(alias)) { + throw new Error(`Duplicate flag/alias: ${alias}`) + } + index.set(alias, param) } } - return lookup + + return { params, index } } const buildSubcommandIndex = ( subcommands: ReadonlyArray> ): Map> => new Map(subcommands.map((sub) => [sub.name, sub])) -const makeFlagSpec = (params: ReadonlyArray): FlagSpec => ({ - params, - index: buildFlagIndex(params) -}) +/* ========================================================================== */ +/* Flag Accumulator */ +/* ========================================================================== */ + +/** Creates an empty flag map with all known flag names initialized to []. */ +const createEmptyFlagMap = (params: ReadonlyArray): FlagMap => + Object.fromEntries(params.map((p) => [p.name, []])) + +/** + * Creates a mutable accumulator for collecting flag values. + * Pre-initializes empty arrays for all known flags. + */ +const createFlagAccumulator = (params: ReadonlyArray): FlagAccumulator => { + const map = createEmptyFlagMap(params) -const makeFlagBag = (params: ReadonlyArray): FlagBag => { - const map = makeFlagMap(params) return { - add: (name, raw) => appendFlagValue(map, name, raw), - merge: (from) => mergeIntoFlagMap(map, from), - snapshot: () => toReadonlyFlagMap(map) + add: (name, raw) => { + if (raw !== undefined) map[name].push(raw) + }, + merge: (from) => { + for (const key in from) { + const values = from[key] + if (values?.length) { + for (let i = 0; i < values.length; i++) { + map[key].push(values[i]) + } + } + } + }, + snapshot: () => map } } -/* ====================================================================== */ -/* Flag bag & values */ -/* ====================================================================== */ - -const isFlagToken = (t: Token): t is Extract => - t._tag === "LongOption" || t._tag === "ShortOption" - -const flagName = (t: Extract) => - t._tag === "LongOption" ? t.name : t.flag +/* ========================================================================== */ +/* Token Classification */ +/* ========================================================================== */ -/** true/false/1/0/yes/no/on/off – if the next token is a boolean literal, return it. */ -const peekBooleanLiteral = (next: Token | undefined): string | undefined => - next?._tag === "Value" && (isTrueValue(next.value) || isFalseValue(next.value)) ? next.value : undefined +type FlagToken = Extract -const makeFlagMap = (params: ReadonlyArray): MutableFlagMap => - Object.fromEntries(params.map((p) => [p.name, [] as Array])) as MutableFlagMap +const isFlagToken = (t: Token): t is FlagToken => t._tag === "LongOption" || t._tag === "ShortOption" -const appendFlagValue = (bag: MutableFlagMap, name: string, raw: string | undefined): void => { - if (raw !== undefined) bag[name].push(raw) -} +const getFlagName = (t: FlagToken): string => t._tag === "LongOption" ? t.name : t.flag -const mergeIntoFlagMap = (into: MutableFlagMap, from: FlagMap | MutableFlagMap): void => { - for (const k in from) { - const src = from[k] - if (src && src.length) { - for (let i = 0; i < src.length; i++) { - into[k].push(src[i]) - } - } - } -} +/** + * Checks if a token is a boolean literal value. + * Recognizes: true/false, yes/no, on/off, 1/0 + */ +const asBooleanLiteral = (token: Token | undefined): string | undefined => + token?._tag === "Value" && (isTrueValue(token.value) || isFalseValue(token.value)) + ? token.value + : undefined -const toReadonlyFlagMap = (map: MutableFlagMap): FlagMap => map +/* ========================================================================== */ +/* Flag Value Consumption */ +/* ========================================================================== */ /** - * Consume a recognized flag's value from the cursor: - * - Inline: --flag=value / -f=value - * - Boolean: implicit "true" or explicit next literal - * - Other: consume the next Value token if present + * Reads a flag's value from the token stream. + * + * Value resolution order: + * 1. Inline value: --flag=value or -f=value + * 2. Boolean special case: implicit "true" or explicit boolean literal + * 3. Next token: consume following Value token if present */ -const readFlagValue = ( +const consumeFlagValue = ( cursor: TokenCursor, - tok: Extract, + token: FlagToken, spec: FlagParam ): string | undefined => { - if (tok.value !== undefined) return tok.value + // Inline value has highest priority + if (token.value !== undefined) { + return token.value + } + + // Boolean flags: check for explicit literal or default to "true" if (spec.primitiveType._tag === "Boolean") { - const explicit = peekBooleanLiteral(cursor.peek()) - if (explicit !== undefined) cursor.take() // consume the literal - return explicit ?? "true" + const literal = asBooleanLiteral(cursor.peek()) + if (literal !== undefined) cursor.take() + return literal ?? "true" } + + // Non-boolean: try to consume next Value token const next = cursor.peek() - if (next && next._tag === "Value") { + if (next?._tag === "Value") { cursor.take() return next.value } - return undefined -} -const unrecognizedFlagError = ( - token: Token, - singles: ReadonlyArray, - commandPath?: ReadonlyArray -): CliError.UnrecognizedOption | undefined => { - if (!isFlagToken(token)) return undefined - const printable = token._tag === "LongOption" ? `--${token.name}` : `-${token.flag}` - const valid: Array = [] - for (const s of singles) { - valid.push(s.name) - for (const alias of s.aliases) { - valid.push(alias) - } - } - const suggestions = suggest(flagName(token), valid).map((n) => (n.length === 1 ? `-${n}` : `--${n}`)) - return new CliError.UnrecognizedOption({ - option: printable, - suggestions, - ...(commandPath && { command: commandPath }) - }) + return undefined } -/* ====================================================================== */ -/* Built-ins peeling – uses the same primitives */ -/* ====================================================================== */ - -const builtInFlagParams: ReadonlyArray = [ - ...Param.extractSingleParams(logLevelFlag), - ...Param.extractSingleParams(helpFlag), - ...Param.extractSingleParams(versionFlag), - ...Param.extractSingleParams(completionsFlag), - ...Param.extractSingleParams(dynamicCompletionsFlag) -] - -const builtInFlagSpec = makeFlagSpec(builtInFlagParams) - -/** Collect only the provided flags; leave everything else untouched as remainder. */ -const collectFlagValues = ( +/** + * Consumes known flags from a token stream. + * Unrecognized tokens are passed through to remainder. + * Used for both built-in extraction and npm-style parent flag collection. + */ +const consumeKnownFlags = ( tokens: ReadonlyArray, - spec: FlagSpec + registry: FlagRegistry ): { flagMap: FlagMap; remainder: ReadonlyArray } => { - const flagMap = makeFlagMap(spec.params) + const flagMap = createEmptyFlagMap(registry.params) const remainder: Array = [] const cursor = makeCursor(tokens) @@ -219,89 +387,74 @@ const collectFlagValues = ( remainder.push(t) continue } - const param = spec.index.get(flagName(t)) + + const param = registry.index.get(getFlagName(t)) if (!param) { - // Not one of the target flags → don't consume a following value remainder.push(t) continue } - appendFlagValue(flagMap, param.name, readFlagValue(cursor, t, param)) + + const value = consumeFlagValue(cursor, t, param) + if (value !== undefined) { + flagMap[param.name].push(value) + } } - return { flagMap: toReadonlyFlagMap(flagMap), remainder } + return { flagMap, remainder } } -/** - * Extract built-in flags using the same machinery. - * - * @internal - */ -export const extractBuiltInOptions = ( - tokens: ReadonlyArray -): Effect.Effect< - { - help: boolean - logLevel: LogLevel | undefined - version: boolean - completions: "bash" | "zsh" | "fish" | undefined - dynamicCompletions: "bash" | "zsh" | "fish" | undefined - remainder: ReadonlyArray - }, - CliError.CliError, - FileSystem | Path -> => - Effect.gen(function*() { - const { flagMap, remainder } = collectFlagValues(tokens, builtInFlagSpec) - const emptyArgs: Param.ParsedArgs = { flags: flagMap, arguments: [] } - const [, help] = yield* helpFlag.parse(emptyArgs) - const [, logLevel] = yield* logLevelFlag.parse(emptyArgs) - const [, version] = yield* versionFlag.parse(emptyArgs) - const [, completions] = yield* completionsFlag.parse(emptyArgs) - const [, dynamicCompletions] = yield* dynamicCompletionsFlag.parse(emptyArgs) - return { - help, - logLevel: Option.getOrUndefined(logLevel), - version, - completions: Option.getOrUndefined(completions), - dynamicCompletions: Option.getOrUndefined(dynamicCompletions), - remainder - } - }) +/* ========================================================================== */ +/* Built-in Flags */ +/* ========================================================================== */ -/* ====================================================================== */ -/* One-level scan */ -/* ====================================================================== */ +const builtInFlagParams: ReadonlyArray = [ + ...Param.extractSingleParams(logLevelFlag), + ...Param.extractSingleParams(helpFlag), + ...Param.extractSingleParams(versionFlag), + ...Param.extractSingleParams(completionsFlag), + ...Param.extractSingleParams(dynamicCompletionsFlag) +] -type LeafResult = { - readonly _tag: "Leaf" - readonly flags: FlagMap - readonly arguments: ReadonlyArray - readonly errors: ReadonlyArray -} +const builtInFlagRegistry = createFlagRegistry(builtInFlagParams) -type SubcommandResult = { - readonly _tag: "Sub" - readonly flags: FlagMap - readonly leadingArguments: ReadonlyArray - readonly sub: Command - readonly childTokens: ReadonlyArray - readonly errors: ReadonlyArray -} +/* ========================================================================== */ +/* Error Creation */ +/* ========================================================================== */ -type LevelResult = LeafResult | SubcommandResult +const createUnrecognizedFlagError = ( + token: FlagToken, + params: ReadonlyArray, + commandPath: ReadonlyArray +): CliError.UnrecognizedOption => { + const printable = token._tag === "LongOption" ? `--${token.name}` : `-${token.flag}` + const validNames: Array = [] -interface ParseState { - readonly flags: FlagBag - readonly arguments: Array - readonly errors: Array - seenFirstValue: boolean + for (const p of params) { + validNames.push(p.name) + for (const alias of p.aliases) { + validNames.push(alias) + } + } + + const suggestions = suggest(getFlagName(token), validNames) + .map((n) => (n.length === 1 ? `-${n}` : `--${n}`)) + + return new CliError.UnrecognizedOption({ + option: printable, + suggestions, + command: commandPath + }) } -const makeParseState = (flagSpec: FlagSpec): ParseState => ({ - flags: makeFlagBag(flagSpec.params), +/* ========================================================================== */ +/* Parse State */ +/* ========================================================================== */ + +const createParseState = (registry: FlagRegistry): ParseState => ({ + flags: createFlagAccumulator(registry.params), arguments: [], errors: [], - seenFirstValue: false + mode: { _tag: "AwaitingFirstValue" } }) const toLeafResult = (state: ParseState): LeafResult => ({ @@ -311,117 +464,152 @@ const toLeafResult = (state: ParseState): LeafResult => ({ errors: state.errors }) -const isFlagParam = (s: Param.Single): s is Param.Single => - s.kind === "flag" +/* ========================================================================== */ +/* First Value Resolution */ +/* ========================================================================== */ -const scanCommandLevel = ( - tokens: ReadonlyArray, - context: CommandContext -): LevelResult => { - const { command, commandPath, flagSpec } = context - const { index } = flagSpec +/** + * Determines how to handle the first value token. + * + * If it matches a known subcommand: + * - Collect any parent flags from remaining tokens (npm-style) + * - Return SubcommandResult with child tokens + * + * Otherwise: + * - Return Argument to treat it as a positional argument + * - Report error if command expects subcommand but got unknown value + */ +const resolveFirstValue = ( + value: string, + cursor: TokenCursor, + context: CommandContext, + state: ParseState +): FirstValueResult => { + const { command, commandPath, flagRegistry } = context const subIndex = buildSubcommandIndex(command.subcommands) - const state = makeParseState(flagSpec) - const expectsArgs = command.config.arguments.length > 0 - const cursor = makeCursor(tokens) + const sub = subIndex.get(value) - const handleFlag = (t: Extract) => { - const spec = index.get(flagName(t)) - if (!spec) { - const err = unrecognizedFlagError(t, flagSpec.params, commandPath) - if (err) state.errors.push(err) - return - } - state.flags.add(spec.name, readFlagValue(cursor, t, spec)) - } + if (sub) { + // npm-style: parent flags can appear after subcommand name + const tail = consumeKnownFlags(cursor.rest(), flagRegistry) + state.flags.merge(tail.flagMap) - const handleFirstValue = (value: string): SubcommandResult | undefined => { - const sub = subIndex.get(value) - if (sub) { - // Allow parent flags to appear after the subcommand name (npm-style) - const tail = collectFlagValues(cursor.rest(), flagSpec) - state.flags.merge(tail.flagMap) - return { + return { + _tag: "Subcommand", + result: { _tag: "Sub", flags: state.flags.snapshot(), - leadingArguments: [], sub, childTokens: tail.remainder, errors: state.errors } } + } - if (!expectsArgs && subIndex.size > 0) { - const suggestions = suggest(value, command.subcommands.map((s) => s.name)) - state.errors.push(new CliError.UnknownSubcommand({ subcommand: value, parent: commandPath, suggestions })) - } - return undefined + // Not a subcommand. Check if this looks like a typo. + const expectsArgs = command.config.arguments.length > 0 + if (!expectsArgs && subIndex.size > 0) { + const suggestions = suggest(value, command.subcommands.map((s) => s.name)) + state.errors.push( + new CliError.UnknownSubcommand({ + subcommand: value, + parent: commandPath, + suggestions + }) + ) } - for (let t = cursor.take(); t; t = cursor.take()) { - if (isFlagToken(t)) { - handleFlag(t) - continue - } + return { _tag: "Argument" } +} - if (t._tag === "Value") { - if (!state.seenFirstValue) { - state.seenFirstValue = true - const sub = handleFirstValue(t.value) - if (sub) return sub - } - state.arguments.push(t.value) - } +/* ========================================================================== */ +/* Token Processing */ +/* ========================================================================== */ + +/** + * Processes a flag token: looks up in registry, consumes value, records it. + * Reports unrecognized flags as errors. + */ +const processFlag = ( + token: FlagToken, + cursor: TokenCursor, + context: CommandContext, + state: ParseState +): void => { + const { commandPath, flagRegistry } = context + const name = getFlagName(token) + const spec = flagRegistry.index.get(name) + + if (!spec) { + state.errors.push(createUnrecognizedFlagError(token, flagRegistry.params, commandPath)) + return } - return toLeafResult(state) + state.flags.add(spec.name, consumeFlagValue(cursor, token, spec)) } -/* ====================================================================== */ -/* Public API */ -/* ====================================================================== */ +/** + * Processes a value token based on current parsing mode. + * + * In AwaitingFirstValue mode: + * - Check if value is a subcommand + * - If so, return SubcommandResult to exit scanning + * - If not, switch to CollectingArguments mode + * + * In CollectingArguments mode: + * - Simply add value to arguments list + */ +const processValue = ( + value: string, + cursor: TokenCursor, + context: CommandContext, + state: ParseState +): SubcommandResult | undefined => { + if (state.mode._tag === "AwaitingFirstValue") { + const result = resolveFirstValue(value, cursor, context, state) + + if (result._tag === "Subcommand") { + return result.result + } -/** @internal */ -export const parseArgs = ( - lexResult: LexResult, - command: Command, - commandPath: ReadonlyArray = [] -): Effect.Effect => - Effect.gen(function*() { - const { tokens, trailingOperands: afterEndOfOptions } = lexResult - const newCommandPath = [...commandPath, command.name] + state.mode = { _tag: "CollectingArguments" } + } - // Flags available at this level (ignore arguments) - const singles = command.config.flags.flatMap(Param.extractSingleParams) - const flagParams = singles.filter(isFlagParam) - const flagSpec = makeFlagSpec(flagParams) + state.arguments.push(value) + return undefined +} - const result = scanCommandLevel(tokens, { - command, - commandPath: newCommandPath, - flagSpec - }) +/* ========================================================================== */ +/* Command Level Scanning */ +/* ========================================================================== */ - if (result._tag === "Leaf") { - return { - flags: result.flags, - arguments: [...result.arguments, ...afterEndOfOptions], - ...(result.errors.length > 0 && { errors: result.errors }) - } - } +/** + * Scans a single command level, processing all tokens. + * + * For each token: + * - Flags: Look up, consume value, record in accumulator + * - Values: Check for subcommand (first value only), then collect as arguments + * + * Returns LeafResult if no subcommand detected, SubcommandResult otherwise. + */ +const scanCommandLevel = ( + tokens: ReadonlyArray, + context: CommandContext +): LevelResult => { + const cursor = makeCursor(tokens) + const state = createParseState(context.flagRegistry) - const subLex: LexResult = { tokens: result.childTokens, trailingOperands: [] } - const subParsed = yield* parseArgs( - subLex, - result.sub as unknown as Command, - newCommandPath - ) + for (let token = cursor.take(); token; token = cursor.take()) { + if (isFlagToken(token)) { + processFlag(token, cursor, context, state) + continue + } - const allErrors = [...result.errors, ...(subParsed.errors ?? [])] - return { - flags: result.flags, - arguments: [...result.leadingArguments, ...afterEndOfOptions], - subcommand: { name: result.sub.name, parsedInput: subParsed }, - ...(allErrors.length > 0 && { errors: allErrors }) + if (token._tag === "Value") { + const subResult = processValue(token.value, cursor, context, state) + if (subResult) return subResult } - }) + } + + return toLeafResult(state) +} diff --git a/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap b/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap index 73050a590..b98e5138b 100644 --- a/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap +++ b/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap @@ -194,6 +194,131 @@ function _forge_forge_deploy_handler() { } +if [ "$funcstack[1]" = "_forge_zsh_completions" ]; then + _forge_zsh_completions "$@" +else + compdef _forge_zsh_completions forge +fi" +`; + +exports[`completions > zsh 2`] = ` +"complete -c forge -n "__fish_use_subcommand" -f -a "build" +complete -c forge -n "__fish_use_subcommand" -f -a "deploy" +complete -c forge -n "__fish_use_subcommand" -l verbose -s v -f +complete -c forge -n "__fish_use_subcommand" -l log-level -r +complete -c forge -n "__fish_seen_subcommand_from build" -l out-dir -r -f -a "(__fish_complete_directories (commandline -ct))" +complete -c forge -n "__fish_seen_subcommand_from build" -l target -r +complete -c forge -n "__fish_seen_subcommand_from deploy" -l env -r +complete -c forge -n "__fish_seen_subcommand_from deploy" -l file -r -f -a "(__fish_complete_path (commandline -ct))"" +`; + +exports[`completions > zsh 3`] = ` +"#compdef forge + +function _forge_zsh_completions() { + _forge_zsh_route +} + +function _forge_zsh_route() { + local -i i=2 + local w + local ctx="forge" + local handler="_forge_forge_handler" + local -i shift_index=0 + + # Walk through words to find the deepest subcommand, + # skipping option values for the current context. + while (( i <= $#words )); do + w=\${words[i]} + if [[ $w == -* ]]; then + case "$ctx" in + forge) + case "$w" in + "--log-level") ((i++));; + esac + ;; + forge_build) + case "$w" in + "--out-dir"|"--target") ((i++));; + esac + ;; + forge_deploy) + case "$w" in + "--env"|"--file") ((i++));; + esac + ;; + esac + else + case "$ctx:$w" in + "forge:build") + ctx="forge_build" + handler="_forge_forge_build_handler" + shift_index=$i + ;; + "forge:deploy") + ctx="forge_deploy" + handler="_forge_forge_deploy_handler" + shift_index=$i + ;; + esac + fi + (( i++ )) + done + + # If we matched a subcommand, drop everything up to it so the child + # handler sees only its own argv (avoids parent options confusing _arguments). + if (( shift_index > 0 )); then + (( CURRENT -= shift_index - 1 )) + if (( CURRENT < 1 )); then CURRENT=1; fi + shift $(( shift_index - 1 )) words + fi + + # Call the most specific handler for the current context + $handler +} + +function _forge_forge_handler() { + local ret=1 + local context state line + typeset -A opt_args + _arguments -C -s -S "--verbose[verbose]" "-v[verbose]" "--log-level=[log-level]:log_level:_message 'value'" "*::subcommand:->sub_forge" + ret=$? + case $state in + sub_forge) + local -a subcmds + subcmds=( + 'build:build command' + 'deploy:deploy command' + ) + _describe -t commands 'subcommand' subcmds && ret=0 + ;; + esac + return ret +} + +function _forge_forge_build_handler() { + local ret=1 + local context state line + typeset -A opt_args + _arguments -C -s -S "--out-dir=[out-dir]:out_dir:_path_files -/" "--target=[target]:target:_message 'value'" + ret=$? + case $state in + esac + return ret +} + +function _forge_forge_deploy_handler() { + local ret=1 + local context state line + typeset -A opt_args + _arguments -C -s -S "--env=[env]:env:_message 'value'" "--file=[file]:file:_files" + ret=$? + case $state in + esac + return ret +} + + if [ "$funcstack[1]" = "_forge_zsh_completions" ]; then _forge_zsh_completions "$@" else From ff4ff6f0720b6f61c3e0ade6bc955ede5634858e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 19:55:42 -0500 Subject: [PATCH 10/29] refactor(cli): dynamic-only completions, extract internal/command.ts - Remove static completion generators (bash.ts, fish.ts, zsh/) - --completions now generates dynamic shims that call binary at runtime - Extract CommandInternal, toImpl, makeCommand to internal/command.ts - makeCommand only accepts parsed ConfigInternal - Remove getHelpDoc from public API --- packages/effect/src/unstable/cli/Command.ts | 557 +++--------------- .../src/unstable/cli/internal/builtInFlags.ts | 19 +- .../src/unstable/cli/internal/command.ts | 265 +++++++++ .../unstable/cli/internal/completions/bash.ts | 109 ---- .../internal/completions/dynamic/handler.ts | 5 +- .../unstable/cli/internal/completions/fish.ts | 57 -- .../cli/internal/completions/index.ts | 66 +-- .../cli/internal/completions/shared.ts | 24 +- .../cli/internal/completions/types.ts | 23 - .../cli/internal/completions/zsh/handlers.ts | 63 -- .../cli/internal/completions/zsh/index.ts | 38 -- .../cli/internal/completions/zsh/router.ts | 98 --- .../cli/internal/completions/zsh/utils.ts | 48 -- .../src/unstable/cli/internal/parser.ts | 14 +- .../test/unstable/cli/Completions.test.ts | 50 -- .../__snapshots__/Completions.test.ts.snap | 327 ---------- 16 files changed, 384 insertions(+), 1379 deletions(-) create mode 100644 packages/effect/src/unstable/cli/internal/command.ts delete mode 100644 packages/effect/src/unstable/cli/internal/completions/bash.ts delete mode 100644 packages/effect/src/unstable/cli/internal/completions/fish.ts delete mode 100644 packages/effect/src/unstable/cli/internal/completions/zsh/handlers.ts delete mode 100644 packages/effect/src/unstable/cli/internal/completions/zsh/index.ts delete mode 100644 packages/effect/src/unstable/cli/internal/completions/zsh/router.ts delete mode 100644 packages/effect/src/unstable/cli/internal/completions/zsh/utils.ts delete mode 100644 packages/effect/test/unstable/cli/Completions.test.ts delete mode 100644 packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index a58fcf725..1478db541 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -5,32 +5,41 @@ import * as Console from "../../Console.ts" import * as Predicate from "../../data/Predicate.ts" import * as Effect from "../../Effect.ts" import { dual } from "../../Function.ts" -import { type Pipeable, pipeArguments } from "../../interfaces/Pipeable.ts" -import { YieldableProto } from "../../internal/core.ts" +import type { Pipeable } from "../../interfaces/Pipeable.ts" import type * as Layer from "../../Layer.ts" import type * as FileSystem from "../../platform/FileSystem.ts" import type * as Path from "../../platform/Path.ts" import type * as Terminal from "../../platform/Terminal.ts" import * as References from "../../References.ts" -import * as ServiceMap from "../../ServiceMap.ts" +import type * as ServiceMap from "../../ServiceMap.ts" import type { Simplify } from "../../types/Types.ts" import * as CliError from "./CliError.ts" -import type { ArgDoc, FlagDoc, HelpDoc, SubcommandDoc } from "./HelpDoc.ts" import * as HelpFormatter from "./HelpFormatter.ts" -import { generateBashCompletions, generateFishCompletions, generateZshCompletions } from "./internal/completions.ts" +import { + checkForDuplicateFlags, + type CommandInternal, + getHelpForCommandPath, + makeCommand, + toImpl, + type TypeId +} from "./internal/command.ts" import { generateDynamicCompletion, handleCompletionRequest, isCompletionRequest -} from "./internal/completions/dynamic/index.ts" -import { type ConfigInternal, isConfigInternal, parseConfig, reconstructTree } from "./internal/config.ts" +} from "./internal/completions/index.ts" +import { type ConfigInternal, parseConfig } from "./internal/config.ts" import * as Lexer from "./internal/lexer.ts" import * as Parser from "./internal/parser.ts" -import * as Param from "./Param.ts" -import * as Primitive from "./Primitive.ts" +import type * as Param from "./Param.ts" import * as Prompt from "./Prompt.ts" -const TypeId = "~effect/cli/Command" as const +// Re-export toImpl for internal modules +export { toImpl } from "./internal/command.ts" + +/* ========================================================================== */ +/* Public Types */ +/* ========================================================================== */ /** * Represents a CLI command with its configuration, handler, and metadata. @@ -99,39 +108,20 @@ export interface Command exten * The subcommands available under this command. */ readonly subcommands: ReadonlyArray> - - /** - * The processed internal representation of the command's configuration. - * Contains flags, arguments, and the tree structure for reconstruction. - */ - readonly config: Command.Config.Internal - - /** - * A service which can be used to extract this command's positional arguments - * and flags in subcommand handlers. - */ - readonly service: ServiceMap.Service, Input> - - /** - * The method which will be invoked to parse the command-line input for the - * command. - */ - readonly parse: (input: RawInput) => Effect.Effect - - /** - * The method which will be invoked with the command-line input and path to - * execute the logic associated with the command. - */ - readonly handle: ( - input: Input, - commandPath: ReadonlyArray - ) => Effect.Effect } /** * @since 4.0.0 */ export declare namespace Command { + /** + * Internal implementation interface with all the machinery. + * For use by internal modules that need access to config, parse, handle, etc. + * + * @internal + */ + export type Internal = CommandInternal + /** * Configuration object for defining command flags, arguments, and nested structures. * @@ -181,25 +171,6 @@ export declare namespace Command { * Created by parsing the user's config. Separates parameters by type * while preserving the original nested structure via the tree. * - * @example - * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" - * - * const cmd = Command.make("deploy", { - * verbose: Flag.boolean("verbose"), - * server: { - * host: Flag.string("host"), - * port: Flag.integer("port") - * }, - * files: Argument.string("files").pipe(Argument.variadic) - * }) - * - * // Access the internal representation: - * // cmd.config.arguments -> [filesParam] - * // cmd.config.flags -> [verboseParam, hostParam, portParam] - * // cmd.config.tree -> preserves nested structure for reconstruction - * ``` - * * @since 4.0.0 * @category models */ @@ -273,29 +244,6 @@ export declare namespace Command { /** * The environment required by CLI commands, including file system and path operations. * - * This type represents the services that CLI commands may need access to, - * particularly for file operations and path manipulations. - * - * @example - * ```ts - * import { Command } from "effect/unstable/cli" - * import { Effect, Console } from "effect" - * import { FileSystem, Path } from "effect/platform" - * - * // Commands that need file system access require Environment - * const readConfig = Command.make("read-config", {}, () => - * Effect.gen(function*() { - * const fs = yield* FileSystem.FileSystem - * const path = yield* Path.Path - * const configPath = path.join(process.cwd(), "config.json") - * const content = yield* fs.readFileString(configPath) - * yield* Console.log(content) - * }) - * ) - * - * // Environment is provided automatically by Command.run - * ``` - * * @since 4.0.0 * @category utility types */ @@ -318,32 +266,6 @@ export type Error = C extends Command< /** * Service context for a specific command, providing access to command input through Effect's service system. * - * Context allows commands and subcommands to access their parsed configuration - * through Effect's dependency injection system. - * - * @example - * ```ts - * import { Console, Effect } from "effect" - * import { Command, Flag } from "effect/unstable/cli" - * - * const parentCommand = Command.make("parent", { - * verbose: Flag.boolean("verbose") - * }) - * - * const childCommand = Command.make("child", {}, () => - * Effect.gen(function*() { - * // Access parent command's context within subcommand - * const parentConfig = yield* parentCommand - * if (parentConfig.verbose) { - * yield* Console.log("Verbose mode enabled from parent") - * } - * })) - * - * const app = parentCommand.pipe( - * Command.withSubcommands(childCommand) - * ) - * ``` - * * @since 4.0.0 * @category models */ @@ -369,15 +291,9 @@ export interface RawInput { } } -const Proto = { - ...YieldableProto, - pipe() { - return pipeArguments(this, arguments) - }, - asEffect(this: Command) { - return this.service.asEffect() - } -} +/* ========================================================================== */ +/* Constructors */ +/* ========================================================================== */ /** * Creates a Command from a name, optional config, optional handler function, and optional description. @@ -426,21 +342,6 @@ const Proto = { * yield* Console.log("Deployment completed successfully") * }) * ) - * - * // Command with complex file operations - * const backup = Command.make("backup", { - * source: Argument.string("source"), - * destination: Flag.string("dest").pipe(Flag.withDescription("Backup destination")), - * compress: Flag.boolean("compress").pipe(Flag.withDefault(false)) - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Backing up ${config.source} to ${config.destination}`) - * if (config.compress) { - * yield* Console.log("Compression enabled") - * } - * // File operations would go here - * }) - * ) * ``` * * @since 4.0.0 @@ -463,12 +364,14 @@ export const make: { name: string, config?: Command.Config, handler?: (config: unknown) => Effect.Effect -) => - makeCommand({ +) => { + const parsedConfig = parseConfig(config ?? {}) + return makeCommand({ name, - config: config ?? {} as Command.Config, + config: parsedConfig, ...(Predicate.isNotUndefined(handler) ? { handle: handler } : {}) - })) as any + }) +}) as any /** * @since 4.0.0 @@ -476,16 +379,21 @@ export const make: { */ export const prompt = ( name: Name, - prompt: Prompt.Prompt, + promptDef: Prompt.Prompt, handler: (value: A) => Effect.Effect ): Command => { + const parsedConfig = parseConfig({}) return makeCommand({ name, - config: {}, - handle: () => Effect.flatMap(Prompt.run(prompt), (value) => handler(value)) + config: parsedConfig, + handle: () => Effect.flatMap(Prompt.run(promptDef), (value) => handler(value)) }) } +/* ========================================================================== */ +/* Combinators */ +/* ========================================================================== */ + /** * Adds or replaces the handler for a command. * @@ -544,7 +452,7 @@ export const withHandler: { } = dual(2, ( self: Command, handler: (value: A) => Effect.Effect -): Command => makeCommand({ ...self, handle: handler })) +): Command => makeCommand({ ...toImpl(self), handle: handler })) /** * Adds subcommands to a command, creating a hierarchical command structure. @@ -591,17 +499,18 @@ export const withSubcommands = => { checkForDuplicateFlags(self, subcommands) + const selfImpl = toImpl(self) type NewInput = Input & { readonly subcommand: ExtractSubcommandInputs | undefined } // Build a stable name → subcommand index to avoid repeated linear scans - const subcommandIndex = new Map>() + const subcommandIndex = new Map>() for (const s of subcommands) { - subcommandIndex.set(s.name, s) + subcommandIndex.set(s.name, toImpl(s)) } const parse: (input: RawInput) => Effect.Effect = Effect.fnUntraced( function*(input: RawInput) { - const parentResult = yield* self.parse(input) + const parentResult = yield* selfImpl.parse(input) const subRef = input.subcommand if (!subRef) { @@ -630,12 +539,12 @@ export const withSubcommands = ( self: Command, description: string -) => makeCommand({ ...self, description })) +) => makeCommand({ ...toImpl(self), description })) -/** - * Generates a HelpDoc structure from a Command. - * - * This structured data can be formatted for display using HelpFormatter. - * getHelpDoc extracts all relevant information from a command including its - * description, usage pattern, flags, arguments, and subcommands to create - * comprehensive help documentation. - * - * @example - * ```ts - * import { Command, Flag, Argument, HelpFormatter } from "effect/unstable/cli" - * import { Effect, Console } from "effect" - * - * // Create a complex command - * const deploy = Command.make("deploy", { - * environment: Flag.string("env").pipe(Flag.withDescription("Target environment")), - * force: Flag.boolean("force").pipe(Flag.withDescription("Force deployment")), - * files: Argument.string("files").pipe(Argument.variadic, Argument.withDescription("Files to deploy")) - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Deploying to ${config.environment}`) - * }) - * ).pipe( - * Command.withDescription("Deploy application to specified environment") - * ) - * - * // Generate help documentation - * const helpDoc = Command.getHelpDoc(deploy) - * // Result contains: - * // { - * // description: "Deploy application to specified environment", - * // usage: "deploy [flags] ", // flags: [ // { name: "env", aliases: ["--env"], type: "string", description: "Target environment", required: true }, - * // { name: "force", aliases: ["--force"], type: "boolean", description: "Force deployment", required: false } - * // ], - * // args: [ - * // { name: "files", type: "string", description: "Files to deploy", required: true, variadic: true } - * // ] - * // } - * - * // Format and display help - * const program = Effect.gen(function*() { - * const helpRenderer = yield* HelpFormatter.HelpRenderer - * const helpText = helpRenderer.formatHelpDoc(helpDoc) - * yield* Console.log(helpText) - * }) - * - * // For subcommand help with command path - * const git = Command.make("git", { - * verbose: Flag.boolean("verbose") - * }).pipe( - * Command.withSubcommands(deploy) - * ) - * const subcommandHelp = Command.getHelpDoc(deploy, ["git", "deploy"]) - * // Usage will show: "git deploy [flags] " - * ``` - * - * @since 4.0.0 - * @category help - */ -export const getHelpDoc = ( - command: Command, - commandPath?: ReadonlyArray -): HelpDoc => { - const args: Array = [] - const flags: Array = [] - - // Extract positional arguments - for (const arg of command.config.arguments) { - const singles = Param.extractSingleParams(arg) - const metadata = Param.getParamMetadata(arg) - - for (const single of singles) { - args.push({ - name: single.name, - type: single.typeName ?? Primitive.getTypeName(single.primitiveType), - description: single.description, - required: !metadata.isOptional, - variadic: metadata.isVariadic - }) - } - } - - // Build usage string with positional arguments - let usage: string - if (commandPath && commandPath.length > 0) { - // Use the full command path if provided - usage = commandPath.join(" ") - } else { - // Fall back to just the command name - usage = command.name - } - - if (command.subcommands.length > 0) { - usage += " " - } - usage += " [flags]" - - // Add positional arguments to usage - for (const arg of args) { - const argName = arg.variadic ? `<${arg.name}...>` : `<${arg.name}>` - usage += ` ${arg.required ? argName : `[${argName}]`}` - } - - // Extract flags from options - for (const option of command.config.flags) { - const singles = Param.extractSingleParams(option) - for (const single of singles) { - const formattedAliases = single.aliases.map((alias) => alias.length === 1 ? `-${alias}` : `--${alias}`) - - flags.push({ - name: single.name, - aliases: formattedAliases, - type: single.typeName ?? Primitive.getTypeName(single.primitiveType), - description: single.description, - required: single.primitiveType._tag !== "Boolean" - }) - } - } - - // Extract subcommand info - const subcommandDocs: Array = command.subcommands.map((sub) => ({ - name: sub.name, - description: sub.description ?? "" - })) - - return { - description: command.description ?? "", - usage, - flags, - ...(args.length > 0 && { args }), - ...(subcommandDocs.length > 0 && { subcommands: subcommandDocs }) - } -} +/* ========================================================================== */ +/* Providing Services */ +/* ========================================================================== */ /** * Provides the handler of a command with the services produced by a layer @@ -850,15 +629,17 @@ export const provide: { } = dual(2, ( self: Command, layer: Layer.Layer | ((input: Input) => Layer.Layer) -) => - makeCommand({ - ...self, +) => { + const selfImpl = toImpl(self) + return makeCommand({ + ...selfImpl, handle: (input, commandPath) => Effect.provide( - self.handle(input, commandPath), + selfImpl.handle(input, commandPath), typeof layer === "function" ? layer(input) : layer ) - })) + }) +}) /** * Provides the handler of a command with the implementation of a service that @@ -883,18 +664,20 @@ export const provideSync: { self: Command, service: ServiceMap.Service, implementation: S | ((input: Input) => S) -) => - makeCommand({ - ...self, +) => { + const selfImpl = toImpl(self) + return makeCommand({ + ...selfImpl, handle: (input, commandPath) => Effect.provideService( - self.handle(input, commandPath), + selfImpl.handle(input, commandPath), service, typeof implementation === "function" ? (implementation as any)(input) : implementation ) - })) + }) +}) /** * Provides the handler of a command with the service produced by an effect @@ -919,16 +702,18 @@ export const provideEffect: { self: Command, service: ServiceMap.Service, effect: Effect.Effect | ((input: Input) => Effect.Effect) -) => - makeCommand({ - ...self, +) => { + const selfImpl = toImpl(self) + return makeCommand({ + ...selfImpl, handle: (input, commandPath) => Effect.provideServiceEffect( - self.handle(input, commandPath), + selfImpl.handle(input, commandPath), service, typeof effect === "function" ? effect(input) : effect ) - })) + }) +}) /** * Allows for execution of an effect, which optionally depends on command-line @@ -950,15 +735,21 @@ export const provideEffectDiscard: { } = dual(2, ( self: Command, effect: Effect.Effect<_, E2, R2> | ((input: Input) => Effect.Effect<_, E2, R2>) -) => - makeCommand({ - ...self, +) => { + const selfImpl = toImpl(self) + return makeCommand({ + ...selfImpl, handle: (input, commandPath) => Effect.andThen( typeof effect === "function" ? effect(input) : effect, - self.handle(input, commandPath) + selfImpl.handle(input, commandPath) ) - })) + }) +}) + +/* ========================================================================== */ +/* Execution */ +/* ========================================================================== */ /** * Runs a command with the provided input arguments. @@ -1006,8 +797,7 @@ export const run: { * Runs a command with explicitly provided arguments instead of using process.argv. * * This function is useful for testing CLI applications or when you want to - * programmatically execute commands with specific arguments. It provides the - * same functionality as `run` but with explicit control over the input arguments. + * programmatically execute commands with specific arguments. * * @example * ```ts @@ -1038,24 +828,6 @@ export const run: { * // Test version display * yield* runCommand(["--version"]) * }) - * - * // Use with different environments - * const deploy = Command.make("deploy", { - * env: Flag.string("env"), - * config: Argument.string("config") - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Deploying to ${config.env} with config ${config.config}`) - * }) - * ) - * - * const deployProgram = Effect.gen(function*() { - * const runDeploy = Command.runWith(deploy, { version: "2.0.0" }) - * - * // Programmatically run with different configurations - * yield* runDeploy(["--env", "staging", "staging.json"]) - * yield* runDeploy(["--env", "production", "prod.json"]) - * }) * ``` * * @since 4.0.0 @@ -1066,8 +838,9 @@ export const runWith = ( config: { readonly version: string } -): (input: ReadonlyArray) => Effect.Effect => - Effect.fnUntraced( +): (input: ReadonlyArray) => Effect.Effect => { + const commandImpl = toImpl(command) + return Effect.fnUntraced( function*(input: ReadonlyArray) { const args = input // Check for dynamic completion request early (before normal parsing) @@ -1080,7 +853,6 @@ export const runWith = ( const { tokens, trailingOperands } = Lexer.lex(args) const { completions, - dynamicCompletions, help, logLevel, remainder, @@ -1096,15 +868,7 @@ export const runWith = ( yield* Console.log(helpText) return } else if (completions !== undefined) { - const script = completions === "bash" - ? generateBashCompletions(command, command.name) - : completions === "fish" - ? generateFishCompletions(command, command.name) - : generateZshCompletions(command, command.name) - yield* Console.log(script) - return - } else if (dynamicCompletions !== undefined) { - const script = generateDynamicCompletion(command, command.name, dynamicCompletions) + const script = generateDynamicCompletion(command, command.name, completions) yield* Console.log(script) return } else if (version && command.subcommands.length === 0) { @@ -1130,7 +894,7 @@ export const runWith = ( return } - const parseResult = yield* Effect.result(command.parse(parsedArgs)) + const parseResult = yield* Effect.result(commandImpl.parse(parsedArgs)) if (parseResult._tag === "Failure") { const error = parseResult.failure const helpRenderer = yield* HelpFormatter.HelpRenderer @@ -1142,14 +906,14 @@ export const runWith = ( yield* Console.log(helpText) // Then show the error in a clearly marked ERROR section (to stderr) - yield* Console.error(helpRenderer.formatError(error)) + yield* Console.error(helpRenderer.formatError(error as CliError.CliError)) return } const parsed = parseResult.success // Create the execution program - const program = command.handle(parsed, [command.name]) + const program = commandImpl.handle(parsed, [command.name]) // Apply log level if provided via built-ins const finalProgram = logLevel !== undefined @@ -1176,133 +940,6 @@ export const runWith = ( }) }), // Preserve prior public behavior: surface original handler errors - Effect.catchTag("UserError", (error: CliError.UserError) => Effect.fail(error.cause as any)) + Effect.catchTag("UserError", (error: CliError.UserError) => Effect.fail(error.cause as E | CliError.CliError)) ) - -// ============================================================================= -// Utilities -// ============================================================================= - -const makeCommand = (options: { - readonly name: Name - readonly config: Command.Config | ConfigInternal - readonly service?: ServiceMap.Service, Input> | undefined - readonly description?: string | undefined - readonly subcommands?: ReadonlyArray> | undefined - readonly parse?: ((input: RawInput) => Effect.Effect) | undefined - readonly handle?: - | ((input: Input, commandPath: ReadonlyArray) => Effect.Effect) - | undefined -}): Command => { - const service = options.service ?? ServiceMap.Service, Input>(`${TypeId}/${options.name}`) - - const config = isConfigInternal(options.config) ? options.config : parseConfig(options.config) - - const handle = ( - input: Input, - commandPath: ReadonlyArray - ): Effect.Effect => - Predicate.isNotUndefined(options.handle) - ? options.handle(input, commandPath) - : Effect.fail(new CliError.ShowHelp({ commandPath })) - - const parse = options.parse ?? Effect.fnUntraced(function*(input: RawInput) { - const parsedArgs: Param.ParsedArgs = { flags: input.flags, arguments: input.arguments } - const values = yield* parseParams(parsedArgs, config.orderedParams) - return reconstructTree(config.tree, values) as Input - }) - - return Object.assign(Object.create(Proto), { - name: options.name, - subcommands: options.subcommands ?? [], - config, - service, - parse, - handle, - ...(Predicate.isNotUndefined(options.description) - ? { description: options.description } - : {}) - }) -} - -/** - * Parses param values from parsed command arguments into their typed - * representations. - */ -const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray) => Effect.Effect< - ReadonlyArray, - CliError.CliError, - Environment -> = Effect.fnUntraced(function*(parsedArgs, params) { - const results: Array = [] - let currentArguments = parsedArgs.arguments - - for (const option of params) { - const [remainingArguments, parsed] = yield* option.parse({ - flags: parsedArgs.flags, - arguments: currentArguments - }) - results.push(parsed) - currentArguments = remainingArguments - } - - return results -}) - -/** - * Checks for duplicate flag names between parent and child commands. - */ -const checkForDuplicateFlags = ( - parent: Command, - subcommands: ReadonlyArray> -): void => { - const parentOptionNames = new Set() - - const extractNames = (options: ReadonlyArray): void => { - for (const option of options) { - const singles = Param.extractSingleParams(option) - for (const single of singles) { - parentOptionNames.add(single.name) - } - } - } - - extractNames(parent.config.flags) - - for (const subcommand of subcommands) { - for (const option of subcommand.config.flags) { - const singles = Param.extractSingleParams(option) - for (const single of singles) { - if (parentOptionNames.has(single.name)) { - throw new CliError.DuplicateOption({ - option: single.name, - parentCommand: parent.name, - childCommand: subcommand.name - }) - } - } - } - } -} - -/** - * Helper function to get help documentation for a specific command path. - * Navigates through the command hierarchy to find the right command. - */ -const getHelpForCommandPath = ( - command: Command, - commandPath: ReadonlyArray -): HelpDoc => { - let currentCommand: Command = command as any - - // Navigate through the command path to find the target command - for (let i = 1; i < commandPath.length; i++) { - const subcommandName = commandPath[i] - const subcommand = currentCommand.subcommands.find((sub) => sub.name === subcommandName) - if (subcommand) { - currentCommand = subcommand - } - } - - return getHelpDoc(currentCommand, commandPath) } diff --git a/packages/effect/src/unstable/cli/internal/builtInFlags.ts b/packages/effect/src/unstable/cli/internal/builtInFlags.ts index 49e49ac4f..0c9d3e161 100644 --- a/packages/effect/src/unstable/cli/internal/builtInFlags.ts +++ b/packages/effect/src/unstable/cli/internal/builtInFlags.ts @@ -62,6 +62,7 @@ export const versionFlag: Flag.Flag = Flag /** * Built-in --completions option to print shell completion scripts. + * Generates a dynamic completion shim that calls the CLI at runtime. * Accepts one of: bash | zsh | fish | sh (alias of bash). * * @since 4.0.0 @@ -73,21 +74,5 @@ export const completionsFlag: Flag.Flag> Flag.optional, // Map "sh" to "bash" while preserving Option-ness Flag.map((v) => Option.map(v, (s) => (s === "sh" ? "bash" : s))), - Flag.withDescription("Print static shell completion script for the given shell") - ) - -/** - * Built-in --dynamic-completions option to print dynamic shell completion scripts. - * Accepts one of: bash | zsh | fish | sh (alias of bash). - * - * @since 4.0.0 - * @internal - */ -export const dynamicCompletionsFlag: Flag.Flag> = Flag - .choice("dynamic-completions", ["bash", "zsh", "fish", "sh"] as const) - .pipe( - Flag.optional, - // Map "sh" to "bash" while preserving Option-ness - Flag.map((v) => Option.map(v, (s) => (s === "sh" ? "bash" : s))), - Flag.withDescription("Print dynamic shell completion script for the given shell") + Flag.withDescription("Print shell completion script for the given shell") ) diff --git a/packages/effect/src/unstable/cli/internal/command.ts b/packages/effect/src/unstable/cli/internal/command.ts new file mode 100644 index 000000000..4f8935008 --- /dev/null +++ b/packages/effect/src/unstable/cli/internal/command.ts @@ -0,0 +1,265 @@ +/** + * Command Implementation + * ====================== + * + * Internal implementation details for CLI commands. + * Public API is in ../Command.ts + */ +import * as Predicate from "../../../data/Predicate.ts" +import * as Effect from "../../../Effect.ts" +import { pipeArguments } from "../../../interfaces/Pipeable.ts" +import { YieldableProto } from "../../../internal/core.ts" +import * as ServiceMap from "../../../ServiceMap.ts" +import * as CliError from "../CliError.ts" +import type { ArgDoc, FlagDoc, HelpDoc, SubcommandDoc } from "../HelpDoc.ts" +import * as Param from "../Param.ts" +import * as Primitive from "../Primitive.ts" +import { type ConfigInternal, reconstructTree } from "./config.ts" + +/* ========================================================================== */ +/* Types */ +/* ========================================================================== */ + +import type { Command, Environment, ParentCommand, RawInput } from "../Command.ts" + +/** + * Internal implementation interface with all the machinery. + * Use toImpl() to access from internal code. + */ +export interface CommandInternal extends Command { + readonly config: ConfigInternal + readonly service: ServiceMap.Service, Input> + readonly parse: (input: RawInput) => Effect.Effect + readonly handle: ( + input: Input, + commandPath: ReadonlyArray + ) => Effect.Effect + readonly buildHelpDoc: (commandPath: ReadonlyArray) => HelpDoc +} + +/* ========================================================================== */ +/* Type ID */ +/* ========================================================================== */ + +export const TypeId = "~effect/cli/Command" as const + +/* ========================================================================== */ +/* Casting */ +/* ========================================================================== */ + +/** + * Casts a Command to its internal implementation. + * For use by internal modules that need access to config, parse, handle, etc. + */ +export const toImpl = ( + self: Command +): CommandInternal => self as CommandInternal + +/* ========================================================================== */ +/* Proto */ +/* ========================================================================== */ + +export const Proto = { + ...YieldableProto, + pipe() { + return pipeArguments(this, arguments) + }, + asEffect(this: Command) { + return toImpl(this).service.asEffect() + } +} + +/* ========================================================================== */ +/* Constructor */ +/* ========================================================================== */ + +/** + * Internal command constructor. Only accepts already-parsed ConfigInternal. + */ +export const makeCommand = (options: { + readonly name: Name + readonly config: ConfigInternal + readonly service?: ServiceMap.Service, Input> | undefined + readonly description?: string | undefined + readonly subcommands?: ReadonlyArray> | undefined + readonly parse?: ((input: RawInput) => Effect.Effect) | undefined + readonly handle?: + | ((input: Input, commandPath: ReadonlyArray) => Effect.Effect) + | undefined +}): Command => { + const service = options.service ?? ServiceMap.Service, Input>(`${TypeId}/${options.name}`) + const config = options.config + + const handle = ( + input: Input, + commandPath: ReadonlyArray + ): Effect.Effect => + Predicate.isNotUndefined(options.handle) + ? options.handle(input, commandPath) + : Effect.fail(new CliError.ShowHelp({ commandPath })) + + const parse = options.parse ?? Effect.fnUntraced(function*(input: RawInput) { + const parsedArgs: Param.ParsedArgs = { flags: input.flags, arguments: input.arguments } + const values = yield* parseParams(parsedArgs, config.orderedParams) + return reconstructTree(config.tree, values) as Input + }) + + const buildHelpDoc = (commandPath: ReadonlyArray): HelpDoc => { + const args: Array = [] + const flags: Array = [] + + for (const arg of config.arguments) { + const singles = Param.extractSingleParams(arg) + const metadata = Param.getParamMetadata(arg) + for (const single of singles) { + args.push({ + name: single.name, + type: single.typeName ?? Primitive.getTypeName(single.primitiveType), + description: single.description, + required: !metadata.isOptional, + variadic: metadata.isVariadic + }) + } + } + + let usage = commandPath.length > 0 ? commandPath.join(" ") : options.name + const subcommands = options.subcommands ?? [] + if (subcommands.length > 0) { + usage += " " + } + usage += " [flags]" + for (const arg of args) { + const argName = arg.variadic ? `<${arg.name}...>` : `<${arg.name}>` + usage += ` ${arg.required ? argName : `[${argName}]`}` + } + + for (const option of config.flags) { + const singles = Param.extractSingleParams(option) + for (const single of singles) { + const formattedAliases = single.aliases.map((alias) => alias.length === 1 ? `-${alias}` : `--${alias}`) + flags.push({ + name: single.name, + aliases: formattedAliases, + type: single.typeName ?? Primitive.getTypeName(single.primitiveType), + description: single.description, + required: single.primitiveType._tag !== "Boolean" + }) + } + } + + const subcommandDocs: Array = subcommands.map((sub) => ({ + name: sub.name, + description: sub.description ?? "" + })) + + return { + description: options.description ?? "", + usage, + flags, + ...(args.length > 0 && { args }), + ...(subcommandDocs.length > 0 && { subcommands: subcommandDocs }) + } + } + + return Object.assign(Object.create(Proto), { + [TypeId]: TypeId, + name: options.name, + subcommands: options.subcommands ?? [], + config, + service, + parse, + handle, + buildHelpDoc, + ...(Predicate.isNotUndefined(options.description) + ? { description: options.description } + : {}) + }) +} + +/* ========================================================================== */ +/* Helpers */ +/* ========================================================================== */ + +/** + * Parses param values from parsed command arguments into their typed + * representations. + */ +const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray) => Effect.Effect< + ReadonlyArray, + CliError.CliError, + Environment +> = Effect.fnUntraced(function*(parsedArgs, params) { + const results: Array = [] + let currentArguments = parsedArgs.arguments + + for (const option of params) { + const [remainingArguments, parsed] = yield* option.parse({ + flags: parsedArgs.flags, + arguments: currentArguments + }) + results.push(parsed) + currentArguments = remainingArguments + } + + return results +}) + +/** + * Checks for duplicate flag names between parent and child commands. + */ +export const checkForDuplicateFlags = ( + parent: Command, + subcommands: ReadonlyArray> +): void => { + const parentImpl = toImpl(parent) + const parentOptionNames = new Set() + + const extractNames = (options: ReadonlyArray): void => { + for (const option of options) { + const singles = Param.extractSingleParams(option) + for (const single of singles) { + parentOptionNames.add(single.name) + } + } + } + + extractNames(parentImpl.config.flags) + + for (const subcommand of subcommands) { + const subImpl = toImpl(subcommand) + for (const option of subImpl.config.flags) { + const singles = Param.extractSingleParams(option) + for (const single of singles) { + if (parentOptionNames.has(single.name)) { + throw new CliError.DuplicateOption({ + option: single.name, + parentCommand: parent.name, + childCommand: subcommand.name + }) + } + } + } + } +} + +/** + * Helper function to get help documentation for a specific command path. + * Navigates through the command hierarchy to find the right command. + */ +export const getHelpForCommandPath = ( + command: Command, + commandPath: ReadonlyArray +): HelpDoc => { + let currentCommand: Command = command as any + + // Navigate through the command path to find the target command + for (let i = 1; i < commandPath.length; i++) { + const subcommandName = commandPath[i] + const subcommand = currentCommand.subcommands.find((sub) => sub.name === subcommandName) + if (subcommand) { + currentCommand = subcommand + } + } + + return toImpl(currentCommand).buildHelpDoc(commandPath) +} diff --git a/packages/effect/src/unstable/cli/internal/completions/bash.ts b/packages/effect/src/unstable/cli/internal/completions/bash.ts deleted file mode 100644 index f301a6e7f..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/bash.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Command } from "../../Command.ts" -import { flattenCommand, getSingles } from "./shared.ts" -import { isDirType, isEitherPath, isFileType, optionRequiresValue } from "./types.ts" - -const optionTokens = (singles: ReadonlyArray): Array => { - const out: Array = [] - for (const s of singles) { - for (const a of s.aliases) { - out.push(a.length === 1 ? `-${a}` : `--${a}`) - } - out.push(`--${s.name}`) - } - return out -} - -/** @internal */ -export const generateBashCompletions = ( - rootCmd: Command, - executableName: string -): string => { - type AnyCommand = Command - - const rows = flattenCommand(rootCmd as AnyCommand) - const funcCases: Array = [] - const cmdCases: Array = [] - - for (const { cmd, trail } of rows) { - const singles = getSingles(cmd.config.flags) - const words = [ - ...optionTokens(singles), - ...cmd.subcommands.map((s) => s.name) - ] - const wordList = words.join(" ") - - const optionCases: Array = [] - for (const s of singles) { - if (!optionRequiresValue(s)) continue - const prevs = [ - ...s.aliases.map((a) => (a.length === 1 ? `-${a}` : `--${a}`)), - `--${s.name}` - ] - const comp = isDirType(s) - ? "$(compgen -d \"${cur}\")" - : (isFileType(s) || isEitherPath(s)) - ? "$(compgen -f \"${cur}\")" - : "\"${cur}\"" - for (const p of prevs) optionCases.push(`"${p}") COMPREPLY=( ${comp} ); return 0 ;;`) - } - - if (trail.length > 1) { - const funcName = `__${executableName}_${trail.join("_")}_opts` - funcCases.push( - ` ,${trail.join(" ")})`, - ` cmd="${funcName}"`, - " ;;" - ) - } - - const funcName = `__${executableName}_${trail.join("_")}_opts` - cmdCases.push( - `${funcName})`, - ` opts="${wordList}"`, - ` if [[ \${cur} == -* || \${COMP_CWORD} -eq ${trail.length} ]] ; then`, - ` COMPREPLY=( $(compgen -W "${wordList}" -- "\${cur}") )`, - " return 0", - " fi", - " case \\\"${prev}\\\" in" - ) - for (const l of optionCases) { - cmdCases.push(` ${l}`) - } - cmdCases.push( - " *)", - " COMPREPLY=()", - " ;;", - " esac", - ` COMPREPLY=( $(compgen -W "${wordList}" -- "\${cur}") )`, - " return 0", - " ;;" - ) - } - - const scriptName = `_${executableName}_bash_completions` - const lines = [ - `function ${scriptName}() {`, - " local i cur prev opts cmd", - " COMPREPLY=()", - " cur=\\\"${COMP_WORDS[COMP_CWORD]}\\\"", - " prev=\\\"${COMP_WORDS[COMP_CWORD-1]}\\\"", - " cmd=\\\"\\\"", - " opts=\\\"\\\"", - " for i in \"${COMP_WORDS[@]}\"; do", - " case \"${cmd},${i}\" in", - ` ,${executableName})`, - ` cmd="__${executableName}_${executableName}_opts"`, - " ;;", - ...funcCases, - " *)", - " ;;", - " esac", - " done", - " case \\\"${cmd}\\\" in", - ...cmdCases.map((l) => ` ${l}`), - " esac", - "}", - `complete -F ${scriptName} -o nosort -o bashdefault -o default ${executableName}` - ] - return lines.join("\n") -} diff --git a/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts b/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts index f580ebd62..1a21de1b3 100644 --- a/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts +++ b/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts @@ -4,6 +4,7 @@ */ import type { Command } from "../../../Command.ts" +import { toImpl } from "../../command.ts" import { getSingles } from "../shared.ts" import { optionRequiresValue } from "../types.ts" import type { SingleFlagMeta } from "../types.ts" @@ -200,7 +201,7 @@ export const generateDynamicCompletions = ( continue } - const singles = getSingles(currentCmd.config.flags) + const singles = getSingles(toImpl(currentCmd).config.flags) const matchingOption = singles.find((s) => optionToken === `--${s.name}` || s.aliases.some((a) => optionToken === (a.length === 1 ? `-${a}` : `--${a}`)) @@ -230,7 +231,7 @@ export const generateDynamicCompletions = ( // Generate completions based on current context const currentWord = context.currentWord - const singles = getSingles(currentCmd.config.flags) + const singles = getSingles(toImpl(currentCmd).config.flags) const equalIndex = currentWord.indexOf("=") if (currentWord.startsWith("-") && equalIndex !== -1) { const optionToken = currentWord.slice(0, equalIndex) diff --git a/packages/effect/src/unstable/cli/internal/completions/fish.ts b/packages/effect/src/unstable/cli/internal/completions/fish.ts deleted file mode 100644 index 6f0a41e6b..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/fish.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Command } from "../../Command.ts" -import { getSingles } from "./shared.ts" -import { isDirType, isEitherPath, isFileType, optionRequiresValue } from "./types.ts" - -/** @internal */ -export const generateFishCompletions = ( - rootCmd: Command, - executableName: string -): string => { - type AnyCommand = Command - const lines: Array = [] - - const dfs = (cmd: AnyCommand, parents: Array = []) => { - const trail = [...parents, cmd.name] - const singles = getSingles(cmd.config.flags) - - for (const sub of cmd.subcommands) { - const parts = [ - "complete", - `-c ${executableName}`, - ...(trail.length === 1 - ? ["-n \"__fish_use_subcommand\""] - : [`-n "__fish_seen_subcommand_from ${trail[trail.length - 1]}"`]), - "-f", - `-a "${sub.name}"` - ] - lines.push(parts.join(" ")) - } - - for (const s of singles) { - const tokens: Array = [] - if (s.name) tokens.push(`-l ${s.name}`) - for (const a of s.aliases) tokens.push(`-s ${a}`) - if (optionRequiresValue(s)) tokens.push("-r") - const parts = [ - "complete", - `-c ${executableName}`, - ...(trail.length === 1 - ? ["-n \"__fish_use_subcommand\""] - : [`-n "__fish_seen_subcommand_from ${trail[trail.length - 1]}"`]), - ...tokens - ] - if (optionRequiresValue(s)) { - if (isDirType(s)) parts.push("-f -a \"(__fish_complete_directories (commandline -ct))\"") - else if (isFileType(s) || isEitherPath(s)) parts.push("-f -a \"(__fish_complete_path (commandline -ct))\"") - } else { - parts.push("-f") - } - lines.push(parts.join(" ")) - } - - for (const sub of cmd.subcommands) dfs(sub as AnyCommand, trail) - } - - dfs(rootCmd as AnyCommand) - return lines.join("\n") -} diff --git a/packages/effect/src/unstable/cli/internal/completions/index.ts b/packages/effect/src/unstable/cli/internal/completions/index.ts index 243ec533d..e82070303 100644 --- a/packages/effect/src/unstable/cli/internal/completions/index.ts +++ b/packages/effect/src/unstable/cli/internal/completions/index.ts @@ -1,58 +1,19 @@ -import type { Command } from "../../Command.ts" -import { generateBashCompletions } from "./bash.ts" -import { generateFishCompletions } from "./fish.ts" -import type { Shell } from "./types.ts" -import { generateZshCompletions } from "./zsh/index.ts" - -/** @internal */ -export const generateCompletions = ( - rootCmd: Command, - executableName: string, - shell: Shell -): string => { - /* - * TODO(completions) - * - Add a `completion` subcommand with `show|install|uninstall ` UX; keep `--completions` as hidden alias - * - Include descriptions for flags/subcommands (zsh `_arguments[...]`, fish `--description`) - * - Support positional argument completions (type hints, choices) - * - Auto-complete `choice` values and add dynamic `withCompletions(ctx)` hooks - * - Consider dynamic completion mode (`__complete`) that consults real parser - * - Unify parent/child flag visibility policy across shells - * - Add PowerShell support if needed - */ - switch (shell) { - case "bash": - return generateBashCompletions(rootCmd, executableName) - case "fish": - return generateFishCompletions(rootCmd, executableName) - case "zsh": - return generateZshCompletions(rootCmd, executableName) - } -} - -// Export the individual generators for testing/advanced usage -export { - /** @internal */ - generateBashCompletions -} from "./bash.ts" - -export { - /** @internal */ - generateFishCompletions -} from "./fish.ts" +/** + * Dynamic completion system. + * + * Generates lightweight shell shims that call the CLI binary at runtime + * to get completions. This approach is simpler to maintain and always + * stays in sync with the actual parser. + */ -export { - /** @internal */ - generateZshCompletions -} from "./zsh/index.ts" - -// Export dynamic completion functions export { /** @internal */ generateDynamicBashCompletion, /** @internal */ generateDynamicCompletion, /** @internal */ + generateDynamicFishCompletion, + /** @internal */ generateDynamicZshCompletion, /** @internal */ handleCompletionRequest, @@ -61,11 +22,4 @@ export { } from "./dynamic/index.ts" /** @internal */ -export type { - /** @internal */ - CommandRow, - /** @internal */ - Shell, - /** @internal */ - SingleFlagMeta -} from "./types.ts" +export type { Shell, SingleFlagMeta } from "./types.ts" diff --git a/packages/effect/src/unstable/cli/internal/completions/shared.ts b/packages/effect/src/unstable/cli/internal/completions/shared.ts index fb937d5fa..baec46eb7 100644 --- a/packages/effect/src/unstable/cli/internal/completions/shared.ts +++ b/packages/effect/src/unstable/cli/internal/completions/shared.ts @@ -1,6 +1,5 @@ -import type { Command } from "../../Command.ts" import * as Param from "../../Param.ts" -import type { CommandRow, SingleFlagMeta } from "./types.ts" +import type { SingleFlagMeta } from "./types.ts" /** @internal */ export const getSingles = (flags: ReadonlyArray): ReadonlyArray => @@ -18,24 +17,3 @@ export const getSingles = (flags: ReadonlyArray): ReadonlyArray( - cmd: Command, - parents: Array = [] -): Array> => { - const here = [...parents, cmd.name] - const rows = [{ trail: here, cmd }] - for (const c of cmd.subcommands) { - const nested = flattenCommand(c as Command, here) - for (const row of nested) rows.push(row) - } - return rows -} - -/** @internal */ -export const idFromTrail = (trail: Array): string => trail.map((p) => p.replace(/-/g, "_")).join("_") - -/** @internal */ -export const handlerName = (trail: Array, executableName: string): string => - `_${executableName}_${idFromTrail(trail)}_handler` diff --git a/packages/effect/src/unstable/cli/internal/completions/types.ts b/packages/effect/src/unstable/cli/internal/completions/types.ts index 98c421ca1..90a1a907c 100644 --- a/packages/effect/src/unstable/cli/internal/completions/types.ts +++ b/packages/effect/src/unstable/cli/internal/completions/types.ts @@ -1,5 +1,3 @@ -import type { Command } from "../../Command.ts" - /** @internal */ export type Shell = "bash" | "zsh" | "fish" @@ -12,26 +10,5 @@ export interface SingleFlagMeta { readonly description?: string } -/** @internal */ -export interface CommandRow< - Name extends string = string, - I = any, - E = any, - R = any -> { - readonly trail: Array - readonly cmd: Command -} - -/** @internal */ -export const isDirType = (s: SingleFlagMeta): boolean => s.typeName === "directory" - -/** @internal */ -export const isFileType = (s: SingleFlagMeta): boolean => s.typeName === "file" - -/** @internal */ -export const isEitherPath = (s: SingleFlagMeta): boolean => - s.typeName === "path" || s.typeName === "either" || s.primitiveTag === "Path" - /** @internal */ export const optionRequiresValue = (s: SingleFlagMeta): boolean => s.primitiveTag !== "Boolean" diff --git a/packages/effect/src/unstable/cli/internal/completions/zsh/handlers.ts b/packages/effect/src/unstable/cli/internal/completions/zsh/handlers.ts deleted file mode 100644 index 0aa313f2f..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/zsh/handlers.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { getSingles, handlerName } from "../shared.ts" -import type { CommandRow } from "../types.ts" -import { buildArgumentSpec, buildSubcommandState, generateSubcommandCompletion } from "./utils.ts" - -/** @internal */ -export const generateHandlers = (rows: ReadonlyArray, executableName: string): Array => { - const handlers: Array = [] - - for (const { cmd, trail } of rows) { - const funcName = handlerName(trail, executableName) - const flagParams = (cmd.config.flags as ReadonlyArray).filter( - (f: any) => f.kind === "flag" - ) - const singles = getSingles(flagParams) - - const specs: Array = [] - for (const s of singles) { - const parts = buildArgumentSpec(s) - for (const part of parts) { - specs.push(part) - } - } - - if (cmd.subcommands.length > 0) { - specs.push(buildSubcommandState(trail)) - } - - handlers.push( - `function ${funcName}() {`, - " local ret=1", - " local context state line", - " typeset -A opt_args" - ) - - if (specs.length > 0) { - const args = specs.join(" ") - handlers.push(` _arguments -C -s -S ${args}`) - } else { - handlers.push(" _arguments -C -s -S") - } - - handlers.push( - " ret=$?", - " case $state in" - ) - - if (cmd.subcommands.length > 0) { - const lines = generateSubcommandCompletion(cmd.subcommands, trail) - for (const line of lines) { - handlers.push(line) - } - } - - handlers.push( - " esac", - " return ret", - "}", - "" - ) - } - - return handlers -} diff --git a/packages/effect/src/unstable/cli/internal/completions/zsh/index.ts b/packages/effect/src/unstable/cli/internal/completions/zsh/index.ts deleted file mode 100644 index 09155bd2d..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/zsh/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Command } from "../../../Command.ts" -import { flattenCommand } from "../shared.ts" -import { generateHandlers } from "./handlers.ts" -import { generateRouter } from "./router.ts" - -/** @internal */ -export const generateZshCompletions = ( - rootCmd: Command, - executableName: string -): string => { - type AnyCommand = Command - - const rows = flattenCommand(rootCmd as AnyCommand) - const handlers = generateHandlers(rows, executableName) - const routerLines = generateRouter(rows, executableName, rootCmd) - - const scriptName = `_${executableName}_zsh_completions` - - const lines: Array = [ - `#compdef ${executableName}`, - "", - `function ${scriptName}() {`, - ` _${executableName}_zsh_route`, - "}", - "", - ...routerLines, - "", - ...handlers, - "", - `if [ "$funcstack[1]" = "${scriptName}" ]; then`, - ` ${scriptName} "$@"`, - "else", - ` compdef ${scriptName} ${executableName}`, - "fi" - ] - - return lines.join("\n") -} diff --git a/packages/effect/src/unstable/cli/internal/completions/zsh/router.ts b/packages/effect/src/unstable/cli/internal/completions/zsh/router.ts deleted file mode 100644 index a91e803c8..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/zsh/router.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { getSingles, handlerName, idFromTrail } from "../shared.ts" -import type { CommandRow } from "../types.ts" -import { optionRequiresValue } from "../types.ts" - -/** @internal */ -export const generateRouter = ( - rows: ReadonlyArray, - executableName: string, - rootCmd: any -): Array => { - const routerName = `_${executableName}_zsh_route` - const rootCtx = idFromTrail([rootCmd.name]) - - // Precompute value-taking tokens and subcommands by context - const valueTakingTokensByCtx: Record> = {} - const subcommandsByCtx: Record> = {} - - for (const { cmd, trail } of rows) { - const ctx = idFromTrail(trail) - const singles = getSingles( - (cmd.config.flags as ReadonlyArray).filter((f: any) => f.kind === "flag") - ) - const tokens: Array = [] - for (const s of singles) { - if (!optionRequiresValue(s)) continue - tokens.push(`--${s.name}`) - for (const a of s.aliases) tokens.push(a.length === 1 ? `-${a}` : `--${a}`) - } - valueTakingTokensByCtx[ctx] = tokens - subcommandsByCtx[ctx] = cmd.subcommands.map((c: any) => c.name) - } - - // Build case blocks for value-taking options per context - const optionSkipCases: Array = [] - for (const [ctx, toks] of Object.entries(valueTakingTokensByCtx)) { - if (toks.length === 0) continue - optionSkipCases.push( - ` ${ctx})`, - ` case "$w" in`, - ` ${toks.map((t) => `"${t}"`).join("|")}) ((i++));;`, - " esac", - " ;;" - ) - } - - // Build dispatch cases for subcommand recognition - const subDispatchCases: Array = [] - for (const { cmd, trail } of rows) { - const ctx = idFromTrail(trail) - for (const sc of cmd.subcommands) { - const nextCtx = idFromTrail([...trail, sc.name]) - subDispatchCases.push( - ` "${ctx}:${sc.name}")`, - ` ctx="${nextCtx}"`, - ` handler="${handlerName([...trail, sc.name], executableName)}"`, - ` shift_index=$i`, - " ;;" - ) - } - } - - return [ - `function ${routerName}() {`, - " local -i i=2", - " local w", - ` local ctx="${rootCtx}"`, - ` local handler="${handlerName([rootCmd.name], executableName)}"`, - " local -i shift_index=0", - "", - " # Walk through words to find the deepest subcommand,", - " # skipping option values for the current context.", - " while (( i <= $#words )); do", - " w=${words[i]}", - " if [[ $w == -* ]]; then", - " case \"$ctx\" in", - ...optionSkipCases, - " esac", - " else", - " case \"$ctx:$w\" in", - ...subDispatchCases, - " esac", - " fi", - " (( i++ ))", - " done", - "", - " # If we matched a subcommand, drop everything up to it so the child", - " # handler sees only its own argv (avoids parent options confusing _arguments).", - " if (( shift_index > 0 )); then", - " (( CURRENT -= shift_index - 1 ))", // Fix: subtract 1 to keep the subcommand name - " if (( CURRENT < 1 )); then CURRENT=1; fi", - " shift $(( shift_index - 1 )) words", // Fix: subtract 1 to keep the subcommand name - " fi", - "", - " # Call the most specific handler for the current context", - " $handler", - "}" - ] -} diff --git a/packages/effect/src/unstable/cli/internal/completions/zsh/utils.ts b/packages/effect/src/unstable/cli/internal/completions/zsh/utils.ts deleted file mode 100644 index 27618f9cc..000000000 --- a/packages/effect/src/unstable/cli/internal/completions/zsh/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { idFromTrail } from "../shared.ts" -import { isDirType, isEitherPath, isFileType, optionRequiresValue, type SingleFlagMeta } from "../types.ts" - -/** @internal */ -export const getValueCompleter = (s: SingleFlagMeta): string => { - if (isDirType(s)) return "_path_files -/" - if (isFileType(s) || isEitherPath(s)) return "_files" - return "_message 'value'" -} - -/** @internal */ -export const buildArgumentSpec = (s: SingleFlagMeta): Array => { - const specs: Array = [] - const desc = s.name - - if (optionRequiresValue(s)) { - const completer = getValueCompleter(s) - specs.push(`"--${s.name}=[${desc}]:${s.name.replace(/-/g, "_")}:${completer}"`) - for (const alias of s.aliases) { - const prefix = alias.length === 1 ? "-" : "--" - specs.push(`"${prefix}${alias}=[${desc}]:${s.name.replace(/-/g, "_")}:${completer}"`) - } - } else { - specs.push(`"--${s.name}[${desc}]"`) - for (const alias of s.aliases) { - const prefix = alias.length === 1 ? "-" : "--" - specs.push(`"${prefix}${alias}[${desc}]"`) - } - } - return specs -} - -/** @internal */ -export const buildSubcommandState = (trail: Array): string => `"*::subcommand:->sub_${idFromTrail(trail)}"` - -/** @internal */ -export const generateSubcommandCompletion = (subcommands: ReadonlyArray, trail: Array): Array => { - const items = subcommands.map((c) => ` '${c.name}:${c.name} command'`) - return [ - ` sub_${idFromTrail(trail)})`, - " local -a subcmds", - " subcmds=(", - ...items, - " )", - " _describe -t commands 'subcommand' subcmds && ret=0", - " ;;" - ] -} diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 4862c99bc..d4adb3f3c 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -34,7 +34,8 @@ import type { Command, RawInput } from "../Command.ts" import * as Param from "../Param.ts" import { isFalseValue, isTrueValue } from "../Primitive.ts" import { suggest } from "./auto-suggest.ts" -import { completionsFlag, dynamicCompletionsFlag, helpFlag, logLevelFlag, versionFlag } from "./builtInFlags.ts" +import { completionsFlag, helpFlag, logLevelFlag, versionFlag } from "./builtInFlags.ts" +import { toImpl } from "./command.ts" import { type LexResult, type Token } from "./lexer.ts" /* ========================================================================== */ @@ -56,7 +57,6 @@ export const extractBuiltInOptions = ( logLevel: LogLevel | undefined version: boolean completions: "bash" | "zsh" | "fish" | undefined - dynamicCompletions: "bash" | "zsh" | "fish" | undefined remainder: ReadonlyArray }, CliError.CliError, @@ -69,13 +69,11 @@ export const extractBuiltInOptions = ( const [, logLevel] = yield* logLevelFlag.parse(emptyArgs) const [, version] = yield* versionFlag.parse(emptyArgs) const [, completions] = yield* completionsFlag.parse(emptyArgs) - const [, dynamicCompletions] = yield* dynamicCompletionsFlag.parse(emptyArgs) return { help, logLevel: Option.getOrUndefined(logLevel), version, completions: Option.getOrUndefined(completions), - dynamicCompletions: Option.getOrUndefined(dynamicCompletions), remainder } }) @@ -90,7 +88,8 @@ export const parseArgs = ( const { tokens, trailingOperands: afterEndOfOptions } = lexResult const newCommandPath = [...commandPath, command.name] - const singles = command.config.flags.flatMap(Param.extractSingleParams) + const commandImpl = toImpl(command) + const singles = commandImpl.config.flags.flatMap(Param.extractSingleParams) const flagParams = singles.filter(Param.isFlagParam) const flagRegistry = createFlagRegistry(flagParams) @@ -411,8 +410,7 @@ const builtInFlagParams: ReadonlyArray = [ ...Param.extractSingleParams(logLevelFlag), ...Param.extractSingleParams(helpFlag), ...Param.extractSingleParams(versionFlag), - ...Param.extractSingleParams(completionsFlag), - ...Param.extractSingleParams(dynamicCompletionsFlag) + ...Param.extractSingleParams(completionsFlag) ] const builtInFlagRegistry = createFlagRegistry(builtInFlagParams) @@ -507,7 +505,7 @@ const resolveFirstValue = ( } // Not a subcommand. Check if this looks like a typo. - const expectsArgs = command.config.arguments.length > 0 + const expectsArgs = toImpl(command).config.arguments.length > 0 if (!expectsArgs && subIndex.size > 0) { const suggestions = suggest(value, command.subcommands.map((s) => s.name)) state.errors.push( diff --git a/packages/effect/test/unstable/cli/Completions.test.ts b/packages/effect/test/unstable/cli/Completions.test.ts deleted file mode 100644 index b0231f1db..000000000 --- a/packages/effect/test/unstable/cli/Completions.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Command, Flag } from "effect/unstable/cli" -import { - generateBashCompletions, - generateFishCompletions, - generateZshCompletions -} from "effect/unstable/cli/internal/completions" -import { describe, expect, it } from "vitest" - -const buildSampleCli = () => { - const build = Command.make("build", { - "out-dir": Flag.directory("out-dir").pipe(Flag.withDescription("Output directory")), - target: Flag.string("target").pipe(Flag.withDescription("Target name")) - }) - - const deploy = Command.make("deploy", { - env: Flag.choice("env", ["staging", "prod"]).pipe(Flag.withDescription("Environment")), - file: Flag.file("file").pipe(Flag.withDescription("Artifact file")) - }) - - const root = Command - .make("forge", { - verbose: Flag.boolean("verbose").pipe(Flag.withAlias("v"), Flag.withDescription("Verbose output")), - "log-level": Flag.choice("log-level", ["debug", "info", "warn", "error"]).pipe( - Flag.withDescription("Set log level") - ) - }) - .pipe(Command.withSubcommands(build, deploy)) - - return root -} - -describe("completions", () => { - const cli = buildSampleCli() - const exe = "forge" - - it("bash", () => { - const script = generateBashCompletions(cli, exe) - expect(script).toMatchSnapshot() - }) - - it("fish", () => { - const script = generateFishCompletions(cli, exe) - expect(script).toMatchSnapshot() - }) - - it("zsh", () => { - const script = generateZshCompletions(cli, exe) - expect(script).toMatchSnapshot() - }) -}) diff --git a/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap b/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap deleted file mode 100644 index b98e5138b..000000000 --- a/packages/effect/test/unstable/cli/__snapshots__/Completions.test.ts.snap +++ /dev/null @@ -1,327 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`completions > bash 1`] = ` -"function _forge_bash_completions() { - local i cur prev opts cmd - COMPREPLY=() - cur=\\"\${COMP_WORDS[COMP_CWORD]}\\" - prev=\\"\${COMP_WORDS[COMP_CWORD-1]}\\" - cmd=\\"\\" - opts=\\"\\" - for i in "\${COMP_WORDS[@]}"; do - case "\${cmd},\${i}" in - ,forge) - cmd="__forge_forge_opts" - ;; - ,forge build) - cmd="__forge_forge_build_opts" - ;; - ,forge deploy) - cmd="__forge_forge_deploy_opts" - ;; - *) - ;; - esac - done - case \\"\${cmd}\\" in - __forge_forge_opts) - opts="-v --verbose --log-level build deploy" - if [[ \${cur} == -* || \${COMP_CWORD} -eq 1 ]] ; then - COMPREPLY=( $(compgen -W "-v --verbose --log-level build deploy" -- "\${cur}") ) - return 0 - fi - case \\"\${prev}\\" in - "--log-level") COMPREPLY=( "\${cur}" ); return 0 ;; - *) - COMPREPLY=() - ;; - esac - COMPREPLY=( $(compgen -W "-v --verbose --log-level build deploy" -- "\${cur}") ) - return 0 - ;; - __forge_forge_build_opts) - opts="--out-dir --target" - if [[ \${cur} == -* || \${COMP_CWORD} -eq 2 ]] ; then - COMPREPLY=( $(compgen -W "--out-dir --target" -- "\${cur}") ) - return 0 - fi - case \\"\${prev}\\" in - "--out-dir") COMPREPLY=( $(compgen -d "\${cur}") ); return 0 ;; - "--target") COMPREPLY=( "\${cur}" ); return 0 ;; - *) - COMPREPLY=() - ;; - esac - COMPREPLY=( $(compgen -W "--out-dir --target" -- "\${cur}") ) - return 0 - ;; - __forge_forge_deploy_opts) - opts="--env --file" - if [[ \${cur} == -* || \${COMP_CWORD} -eq 2 ]] ; then - COMPREPLY=( $(compgen -W "--env --file" -- "\${cur}") ) - return 0 - fi - case \\"\${prev}\\" in - "--env") COMPREPLY=( "\${cur}" ); return 0 ;; - "--file") COMPREPLY=( $(compgen -f "\${cur}") ); return 0 ;; - *) - COMPREPLY=() - ;; - esac - COMPREPLY=( $(compgen -W "--env --file" -- "\${cur}") ) - return 0 - ;; - esac -} -complete -F _forge_bash_completions -o nosort -o bashdefault -o default forge" -`; - -exports[`completions > fish 1`] = ` -"complete -c forge -n "__fish_use_subcommand" -f -a "build" -complete -c forge -n "__fish_use_subcommand" -f -a "deploy" -complete -c forge -n "__fish_use_subcommand" -l verbose -s v -f -complete -c forge -n "__fish_use_subcommand" -l log-level -r -complete -c forge -n "__fish_seen_subcommand_from build" -l out-dir -r -f -a "(__fish_complete_directories (commandline -ct))" -complete -c forge -n "__fish_seen_subcommand_from build" -l target -r -complete -c forge -n "__fish_seen_subcommand_from deploy" -l env -r -complete -c forge -n "__fish_seen_subcommand_from deploy" -l file -r -f -a "(__fish_complete_path (commandline -ct))"" -`; - -exports[`completions > zsh 1`] = ` -"#compdef forge - -function _forge_zsh_completions() { - _forge_zsh_route -} - -function _forge_zsh_route() { - local -i i=2 - local w - local ctx="forge" - local handler="_forge_forge_handler" - local -i shift_index=0 - - # Walk through words to find the deepest subcommand, - # skipping option values for the current context. - while (( i <= $#words )); do - w=\${words[i]} - if [[ $w == -* ]]; then - case "$ctx" in - forge) - case "$w" in - "--log-level") ((i++));; - esac - ;; - forge_build) - case "$w" in - "--out-dir"|"--target") ((i++));; - esac - ;; - forge_deploy) - case "$w" in - "--env"|"--file") ((i++));; - esac - ;; - esac - else - case "$ctx:$w" in - "forge:build") - ctx="forge_build" - handler="_forge_forge_build_handler" - shift_index=$i - ;; - "forge:deploy") - ctx="forge_deploy" - handler="_forge_forge_deploy_handler" - shift_index=$i - ;; - esac - fi - (( i++ )) - done - - # If we matched a subcommand, drop everything up to it so the child - # handler sees only its own argv (avoids parent options confusing _arguments). - if (( shift_index > 0 )); then - (( CURRENT -= shift_index - 1 )) - if (( CURRENT < 1 )); then CURRENT=1; fi - shift $(( shift_index - 1 )) words - fi - - # Call the most specific handler for the current context - $handler -} - -function _forge_forge_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--verbose[verbose]" "-v[verbose]" "--log-level=[log-level]:log_level:_message 'value'" "*::subcommand:->sub_forge" - ret=$? - case $state in - sub_forge) - local -a subcmds - subcmds=( - 'build:build command' - 'deploy:deploy command' - ) - _describe -t commands 'subcommand' subcmds && ret=0 - ;; - esac - return ret -} - -function _forge_forge_build_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--out-dir=[out-dir]:out_dir:_path_files -/" "--target=[target]:target:_message 'value'" - ret=$? - case $state in - esac - return ret -} - -function _forge_forge_deploy_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--env=[env]:env:_message 'value'" "--file=[file]:file:_files" - ret=$? - case $state in - esac - return ret -} - - -if [ "$funcstack[1]" = "_forge_zsh_completions" ]; then - _forge_zsh_completions "$@" -else - compdef _forge_zsh_completions forge -fi" -`; - -exports[`completions > zsh 2`] = ` -"complete -c forge -n "__fish_use_subcommand" -f -a "build" -complete -c forge -n "__fish_use_subcommand" -f -a "deploy" -complete -c forge -n "__fish_use_subcommand" -l verbose -s v -f -complete -c forge -n "__fish_use_subcommand" -l log-level -r -complete -c forge -n "__fish_seen_subcommand_from build" -l out-dir -r -f -a "(__fish_complete_directories (commandline -ct))" -complete -c forge -n "__fish_seen_subcommand_from build" -l target -r -complete -c forge -n "__fish_seen_subcommand_from deploy" -l env -r -complete -c forge -n "__fish_seen_subcommand_from deploy" -l file -r -f -a "(__fish_complete_path (commandline -ct))"" -`; - -exports[`completions > zsh 3`] = ` -"#compdef forge - -function _forge_zsh_completions() { - _forge_zsh_route -} - -function _forge_zsh_route() { - local -i i=2 - local w - local ctx="forge" - local handler="_forge_forge_handler" - local -i shift_index=0 - - # Walk through words to find the deepest subcommand, - # skipping option values for the current context. - while (( i <= $#words )); do - w=\${words[i]} - if [[ $w == -* ]]; then - case "$ctx" in - forge) - case "$w" in - "--log-level") ((i++));; - esac - ;; - forge_build) - case "$w" in - "--out-dir"|"--target") ((i++));; - esac - ;; - forge_deploy) - case "$w" in - "--env"|"--file") ((i++));; - esac - ;; - esac - else - case "$ctx:$w" in - "forge:build") - ctx="forge_build" - handler="_forge_forge_build_handler" - shift_index=$i - ;; - "forge:deploy") - ctx="forge_deploy" - handler="_forge_forge_deploy_handler" - shift_index=$i - ;; - esac - fi - (( i++ )) - done - - # If we matched a subcommand, drop everything up to it so the child - # handler sees only its own argv (avoids parent options confusing _arguments). - if (( shift_index > 0 )); then - (( CURRENT -= shift_index - 1 )) - if (( CURRENT < 1 )); then CURRENT=1; fi - shift $(( shift_index - 1 )) words - fi - - # Call the most specific handler for the current context - $handler -} - -function _forge_forge_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--verbose[verbose]" "-v[verbose]" "--log-level=[log-level]:log_level:_message 'value'" "*::subcommand:->sub_forge" - ret=$? - case $state in - sub_forge) - local -a subcmds - subcmds=( - 'build:build command' - 'deploy:deploy command' - ) - _describe -t commands 'subcommand' subcmds && ret=0 - ;; - esac - return ret -} - -function _forge_forge_build_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--out-dir=[out-dir]:out_dir:_path_files -/" "--target=[target]:target:_message 'value'" - ret=$? - case $state in - esac - return ret -} - -function _forge_forge_deploy_handler() { - local ret=1 - local context state line - typeset -A opt_args - _arguments -C -s -S "--env=[env]:env:_message 'value'" "--file=[file]:file:_files" - ret=$? - case $state in - esac - return ret -} - - -if [ "$funcstack[1]" = "_forge_zsh_completions" ]; then - _forge_zsh_completions "$@" -else - compdef _forge_zsh_completions forge -fi" -`; From 03219c50e754a454408322603102c5f3cae9e842 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 20:01:15 -0500 Subject: [PATCH 11/29] refactor(cli): remove internal types from Command namespace --- packages/effect/src/unstable/cli/Command.ts | 40 ++------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 1478db541..991004614 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -28,7 +28,7 @@ import { handleCompletionRequest, isCompletionRequest } from "./internal/completions/index.ts" -import { type ConfigInternal, parseConfig } from "./internal/config.ts" +import { parseConfig } from "./internal/config.ts" import * as Lexer from "./internal/lexer.ts" import * as Parser from "./internal/parser.ts" import type * as Param from "./Param.ts" @@ -114,14 +114,6 @@ export interface Command exten * @since 4.0.0 */ export declare namespace Command { - /** - * Internal implementation interface with all the machinery. - * For use by internal modules that need access to config, parse, handle, etc. - * - * @internal - */ - export type Internal = CommandInternal - /** * Configuration object for defining command flags, arguments, and nested structures. * @@ -165,34 +157,6 @@ export declare namespace Command { } export namespace Config { - /** - * The processed internal representation of a Command.Config declaration. - * - * Created by parsing the user's config. Separates parameters by type - * while preserving the original nested structure via the tree. - * - * @since 4.0.0 - * @category models - */ - export type Internal = ConfigInternal - - export namespace Internal { - /** - * Maps declaration keys to their node representations. - * Preserves the shape of the user's config object. - */ - export type Tree = ConfigInternal.Tree - - /** - * A node in the config tree. - * - * - Param: References a parameter by index in orderedParams - * - Array: Contains child nodes for tuple/array declarations - * - Nested: Contains a subtree for nested config objects - */ - export type Node = ConfigInternal.Node - } - /** * Infers the TypeScript type from a Command.Config structure. * @@ -503,7 +467,7 @@ export const withSubcommands = | undefined } // Build a stable name → subcommand index to avoid repeated linear scans - const subcommandIndex = new Map>() + const subcommandIndex = new Map>() for (const s of subcommands) { subcommandIndex.set(s.name, toImpl(s)) } From 304e3c28423993f6476a2b611ad42dddee945cb1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 20:22:21 -0500 Subject: [PATCH 12/29] refactor(cli): make withSubcommands a proper dual with array param - Change from variadic to array parameter: withSubcommands([a, b]) - Add showHelp helper to deduplicate error handling - Remove unnecessary CliError cast - Update all test files to use new array syntax --- packages/effect/src/unstable/cli/Command.ts | 99 ++++++++++++------- .../effect/test/unstable/cli/Command.test.ts | 18 ++-- .../effect/test/unstable/cli/Errors.test.ts | 2 +- .../effect/test/unstable/cli/LogLevel.test.ts | 2 +- .../unstable/cli/completions/dynamic.test.ts | 10 +- .../unstable/cli/fixtures/ComprehensiveCli.ts | 16 +-- 6 files changed, 85 insertions(+), 62 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 991004614..3874cd292 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -442,19 +442,56 @@ export const withHandler: { * }) * ) * + * // Data-last (pipeable) * const git = Command.make("git", {}, () => Effect.void).pipe( - * Command.withSubcommands(clone, add) + * Command.withSubcommands([clone, add]) + * ) + * + * // Data-first + * const git2 = Command.withSubcommands( + * Command.make("git", {}, () => Effect.void), + * [clone, add] * ) * ``` * * @since 4.0.0 * @category combinators */ -export const withSubcommands = >>( - ...subcommands: Subcommands -) => -( - self: Command +export const withSubcommands: { + >>( + subcommands: Subcommands + ): ( + self: Command + ) => Command< + Name, + Input & { readonly subcommand: ExtractSubcommandInputs | undefined }, + ExtractSubcommandErrors, + R | Exclude, ParentCommand> + > + < + Name extends string, + Input, + E, + R, + const Subcommands extends ReadonlyArray> + >( + self: Command, + subcommands: Subcommands + ): Command< + Name, + Input & { readonly subcommand: ExtractSubcommandInputs | undefined }, + ExtractSubcommandErrors, + R | Exclude, ParentCommand> + > +} = dual(2, < + Name extends string, + Input, + E, + R, + const Subcommands extends ReadonlyArray> +>( + self: Command, + subcommands: Subcommands ): Command< Name, Input & { readonly subcommand: ExtractSubcommandInputs | undefined }, @@ -509,7 +546,7 @@ export const withSubcommands = > = T extends readonly [] ? never @@ -715,6 +752,20 @@ export const provideEffectDiscard: { /* Execution */ /* ========================================================================== */ +const showHelp = ( + command: Command, + commandPath: ReadonlyArray, + error?: CliError.CliError +): Effect.Effect => + Effect.gen(function*() { + const helpRenderer = yield* HelpFormatter.HelpRenderer + const helpDoc = getHelpForCommandPath(command, commandPath) + yield* Console.log(helpRenderer.formatHelpDoc(helpDoc)) + if (error) { + yield* Console.error(helpRenderer.formatError(error)) + } + }) + /** * Runs a command with the provided input arguments. * @@ -843,35 +894,15 @@ export const runWith = ( // If there are parsing errors and no help was requested, format and display the error if (parsedArgs.errors && parsedArgs.errors.length > 0) { - const error = parsedArgs.errors[0] - const helpRenderer = yield* HelpFormatter.HelpRenderer const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] - const helpDoc = getHelpForCommandPath(command, commandPath) - - // Show the full help first (to stdout with normal colors) - const helpText = helpRenderer.formatHelpDoc(helpDoc) - yield* Console.log(helpText) - - // Then show the error in a clearly marked ERROR section (to stderr) - yield* Console.error(helpRenderer.formatError(error)) - + yield* showHelp(command, commandPath, parsedArgs.errors[0]) return } const parseResult = yield* Effect.result(commandImpl.parse(parsedArgs)) if (parseResult._tag === "Failure") { - const error = parseResult.failure - const helpRenderer = yield* HelpFormatter.HelpRenderer const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] - const helpDoc = getHelpForCommandPath(command, commandPath) - - // Show the full help first (to stdout with normal colors) - const helpText = helpRenderer.formatHelpDoc(helpDoc) - yield* Console.log(helpText) - - // Then show the error in a clearly marked ERROR section (to stderr) - yield* Console.error(helpRenderer.formatError(error as CliError.CliError)) - + yield* showHelp(command, commandPath, parseResult.failure) return } const parsed = parseResult.success @@ -894,15 +925,7 @@ export const runWith = ( yield* normalized }, - Effect.catchTags({ - ShowHelp: (error: CliError.ShowHelp) => - Effect.gen(function*() { - const helpDoc = getHelpForCommandPath(command, error.commandPath) - const helpRenderer = yield* HelpFormatter.HelpRenderer - const helpText = helpRenderer.formatHelpDoc(helpDoc) - yield* Console.log(helpText) - }) - }), + Effect.catchTag("ShowHelp", (error: CliError.ShowHelp) => showHelp(command, error.commandPath)), // Preserve prior public behavior: surface original handler errors Effect.catchTag("UserError", (error: CliError.UserError) => Effect.fail(error.cause as E | CliError.CliError)) ) diff --git a/packages/effect/test/unstable/cli/Command.test.ts b/packages/effect/test/unstable/cli/Command.test.ts index a9caa109d..b17c5e956 100644 --- a/packages/effect/test/unstable/cli/Command.test.ts +++ b/packages/effect/test/unstable/cli/Command.test.ts @@ -350,7 +350,7 @@ describe("Command", () => { // Combine parent and child const combined = parent.pipe( - Command.withSubcommands(child) + Command.withSubcommands([child]) ) const runCommand = Command.runWith(combined, { @@ -390,7 +390,7 @@ describe("Command", () => { messages.push(`install: global=${parentConfig.global}, pkg=${config.pkg}`) })) - const npm = root.pipe(Command.withSubcommands(install)) + const npm = root.pipe(Command.withSubcommands([install])) const runNpm = Command.runWith(npm, { version: "1.0.0" }) @@ -423,7 +423,7 @@ describe("Command", () => { messages.push(`install: global=${parentConfig.global}, pkg=${config.pkg}`) })) - const npm = root.pipe(Command.withSubcommands(install)) + const npm = root.pipe(Command.withSubcommands([install])) const runNpm = Command.runWith(npm, { version: "1.0.0" }) @@ -471,11 +471,11 @@ describe("Command", () => { // Build the nested command structure const serviceWithDeploy = service.pipe( - Command.withSubcommands(deploy) + Command.withSubcommands([deploy]) ) const appWithService = root.pipe( - Command.withSubcommands(serviceWithDeploy) + Command.withSubcommands([serviceWithDeploy]) ) const runCommand = Command.runWith(appWithService, { version: "1.0.0" }) @@ -522,7 +522,7 @@ describe("Command", () => { // Combine commands const combined = parent.pipe( - Command.withSubcommands(deploy) + Command.withSubcommands([deploy]) ) const runCommand = Command.runWith(combined, { version: "1.0.0" }) @@ -560,7 +560,7 @@ describe("Command", () => { childInvoked = true })) - const cli = root.pipe(Command.withSubcommands(child)) + const cli = root.pipe(Command.withSubcommands([child])) const runCli = Command.runWith(cli, { version: "1.0.0" }) yield* runCli(["--", "child", "--value", "x"]) @@ -639,7 +639,7 @@ describe("Command", () => { value: Flag.string("value") }) - const cli = root.pipe(Command.withSubcommands(child)) + const cli = root.pipe(Command.withSubcommands([child])) const runCli = Command.runWith(cli, { version: "1.0.0" }) yield* runCli(["--global", "--", "child", "--value", "x"]) @@ -651,7 +651,7 @@ describe("Command", () => { Effect.gen(function*() { const root = Command.make("root", {}) const known = Command.make("known", {}) - const cli = root.pipe(Command.withSubcommands(known)) + const cli = root.pipe(Command.withSubcommands([known])) const runCli = Command.runWith(cli, { version: "1.0.0" }) yield* runCli(["--unknown", "bogus"]) diff --git a/packages/effect/test/unstable/cli/Errors.test.ts b/packages/effect/test/unstable/cli/Errors.test.ts index 54de908a5..a24da20e9 100644 --- a/packages/effect/test/unstable/cli/Errors.test.ts +++ b/packages/effect/test/unstable/cli/Errors.test.ts @@ -40,7 +40,7 @@ describe("Command errors", () => { }) try { - parent.pipe(Command.withSubcommands(child)) + parent.pipe(Command.withSubcommands([child])) assert.fail("expected DuplicateOption to be thrown") } catch (error) { assert.instanceOf(error, CliError.DuplicateOption) diff --git a/packages/effect/test/unstable/cli/LogLevel.test.ts b/packages/effect/test/unstable/cli/LogLevel.test.ts index 45807f297..7818ea7b2 100644 --- a/packages/effect/test/unstable/cli/LogLevel.test.ts +++ b/packages/effect/test/unstable/cli/LogLevel.test.ts @@ -144,7 +144,7 @@ describe("LogLevel", () => { yield* Effect.logError("error from child") })) - const combined = parentCommand.pipe(Command.withSubcommands(childCommand)) + const combined = parentCommand.pipe(Command.withSubcommands([childCommand])) const runCommand = Command.runWith(combined, { version: "1.0.0" }) yield* runCommand(["--log-level", "info", "child"]).pipe(Effect.provide(TestLayer)) diff --git a/packages/effect/test/unstable/cli/completions/dynamic.test.ts b/packages/effect/test/unstable/cli/completions/dynamic.test.ts index 48bb7a10d..85fd189b8 100644 --- a/packages/effect/test/unstable/cli/completions/dynamic.test.ts +++ b/packages/effect/test/unstable/cli/completions/dynamic.test.ts @@ -113,21 +113,21 @@ describe("Dynamic Completion Handler", () => { dryRun: Flag.boolean("dry-run") }).pipe( Command.withDescription("Deploy the application"), - Command.withSubcommands( + Command.withSubcommands([ Command.make("staging", { force: Flag.boolean("force") }, () => Effect.void), Command.make("production", { confirm: Flag.boolean("confirm") }, () => Effect.void) - ) + ]) ) return Command.make("myapp", { verbose: Flag.boolean("verbose").pipe(Flag.withAlias("v")), config: Flag.file("config").pipe(Flag.withAlias("c")) }).pipe( - Command.withSubcommands(build, deploy) + Command.withSubcommands([build, deploy]) ) } @@ -470,10 +470,10 @@ describe("Dynamic Completion Handler", () => { it.effect("should output completions to console", () => Effect.gen(function*() { const cmd = Command.make("test", {}).pipe( - Command.withSubcommands( + Command.withSubcommands([ Command.make("sub1", {}, () => Effect.void), Command.make("sub2", {}, () => Effect.void) - ) + ]) ) const originalEnv = { ...process.env } diff --git a/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts b/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts index 20880ba48..a5fabb855 100644 --- a/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts +++ b/packages/effect/test/unstable/cli/fixtures/ComprehensiveCli.ts @@ -60,7 +60,7 @@ const usersCreate = Command.make("create", { const users = Command.make("users").pipe( Command.withDescription("User management commands"), - Command.withSubcommands(usersList, usersCreate) + Command.withSubcommands([usersList, usersCreate]) ) const configSet = Command.make("set", { @@ -110,7 +110,7 @@ const config = Command.make("config", { ) }).pipe( Command.withDescription("Manage application configuration"), - Command.withSubcommands(configSet, configGet) + Command.withSubcommands([configSet, configGet]) ) const admin = Command.make("admin", { @@ -120,7 +120,7 @@ const admin = Command.make("admin", { ) }).pipe( Command.withDescription("Administrative commands"), - Command.withSubcommands(users, config) + Command.withSubcommands([users, config]) ) // File operations commands @@ -281,7 +281,7 @@ const git = Command.make("git", { verbose: config.verbose })).pipe( Command.withDescription("Git version control"), - Command.withSubcommands(gitClone, gitAdd, gitStatus) + Command.withSubcommands([gitClone, gitAdd, gitStatus]) ) // Commands for testing error handling @@ -352,7 +352,7 @@ const app = Command.make("app", { env: config.env })).pipe( Command.withDescription("Application management"), - Command.withSubcommands(deployCommand) + Command.withSubcommands([deployCommand]) ) // Service command for nested context sharing tests @@ -365,7 +365,7 @@ const serviceCommand = Command.make("service", { name: config.name })).pipe( Command.withDescription("Service management"), - Command.withSubcommands(deployCommand) + Command.withSubcommands([deployCommand]) ) const appWithService = Command.make("app-nested", { @@ -377,7 +377,7 @@ const appWithService = Command.make("app-nested", { env: config.env })).pipe( Command.withDescription("Application with nested services"), - Command.withSubcommands(serviceCommand) + Command.withSubcommands([serviceCommand]) ) // Main command with global options @@ -403,7 +403,7 @@ export const ComprehensiveCli = Command.make("mycli", { quiet: config.quiet })).pipe( Command.withDescription("A comprehensive CLI tool demonstrating all features"), - Command.withSubcommands(admin, copy, move, remove, build, git, testRequired, testFailing, app, appWithService) + Command.withSubcommands([admin, copy, move, remove, build, git, testRequired, testFailing, app, appWithService]) ) export const run = Command.runWith(ComprehensiveCli, { From c091c23462b8e76057a269406160a22b72db2c43 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 20:33:33 -0500 Subject: [PATCH 13/29] refactor(cli): clean up withSubcommands and reduce as-any casts - Rewrite withSubcommands with clearer structure and minimal casts - Add TODO comment on prompt constructor semantic mismatch - Use explicit type aliases for NewInput, NewService - Document remaining casts with explanatory comments --- packages/effect/src/unstable/cli/Command.ts | 79 +++++++++++---------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 3874cd292..f71f97121 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -15,14 +15,7 @@ import type * as ServiceMap from "../../ServiceMap.ts" import type { Simplify } from "../../types/Types.ts" import * as CliError from "./CliError.ts" import * as HelpFormatter from "./HelpFormatter.ts" -import { - checkForDuplicateFlags, - type CommandInternal, - getHelpForCommandPath, - makeCommand, - toImpl, - type TypeId -} from "./internal/command.ts" +import { checkForDuplicateFlags, getHelpForCommandPath, makeCommand, toImpl, type TypeId } from "./internal/command.ts" import { generateDynamicCompletion, handleCompletionRequest, @@ -341,6 +334,9 @@ export const make: { * @since 4.0.0 * @category constructors */ +// TODO: Input type is `A` but parse returns `{}`. The actual `A` comes from +// running the prompt inside the handler. This is a semantic mismatch that +// would break if subcommands tried to access parent config. export const prompt = ( name: Name, promptDef: Prompt.Prompt, @@ -500,52 +496,59 @@ export const withSubcommands: { > => { checkForDuplicateFlags(self, subcommands) - const selfImpl = toImpl(self) - type NewInput = Input & { readonly subcommand: ExtractSubcommandInputs | undefined } + const impl = toImpl(self) + const byName = new Map(subcommands.map((s) => [s.name, toImpl(s)] as const)) - // Build a stable name → subcommand index to avoid repeated linear scans - const subcommandIndex = new Map>() - for (const s of subcommands) { - subcommandIndex.set(s.name, toImpl(s)) - } + // Type aliases for the widened command signature + type Subcommand = ExtractSubcommandInputs + type NewInput = Input & { readonly subcommand: Subcommand | undefined } - const parse: (input: RawInput) => Effect.Effect = Effect.fnUntraced( - function*(input: RawInput) { - const parentResult = yield* selfImpl.parse(input) + // Extend parent parsing with subcommand resolution + // Cast needed: TS can't prove { ...parent, subcommand } satisfies NewInput + const parse: (raw: RawInput) => Effect.Effect = Effect.fnUntraced( + function*(raw: RawInput) { + const parent = yield* impl.parse(raw) - const subRef = input.subcommand - if (!subRef) { - return { ...parentResult, subcommand: undefined } + if (!raw.subcommand) { + return { ...parent, subcommand: undefined } as NewInput } - const sub = subcommandIndex.get(subRef.name) - - // Parser guarantees valid subcommand names, but guard defensively + const sub = byName.get(raw.subcommand.name) if (!sub) { - return { ...parentResult, subcommand: undefined } + return { ...parent, subcommand: undefined } as NewInput } - const subResult = yield* sub.parse(subRef.parsedInput) - const subcommand = { name: sub.name, result: subResult } as ExtractSubcommandInputs - return { ...parentResult, subcommand } + const result = yield* sub.parse(raw.subcommand.parsedInput) + return { ...parent, subcommand: { name: sub.name, result } } as NewInput } ) - const handle = Effect.fnUntraced(function*(input: NewInput, commandPath: ReadonlyArray) { - const selected = input.subcommand - if (selected !== undefined) { - const child = subcommandIndex.get(selected.name) + // Route execution to subcommand handler or parent handler + const handle = Effect.fnUntraced(function*(input: NewInput, path: ReadonlyArray) { + if (input.subcommand) { + const child = byName.get(input.subcommand.name) if (!child) { - return yield* new CliError.ShowHelp({ commandPath }) + return yield* new CliError.ShowHelp({ commandPath: path }) } return yield* child - .handle(selected.result, [...commandPath, child.name]) - .pipe(Effect.provideService(selfImpl.service, input)) + .handle(input.subcommand.result, [...path, child.name]) + .pipe(Effect.provideService(impl.service, input)) } - return yield* selfImpl.handle(input, commandPath) + return yield* impl.handle(input as Input, path) }) - return makeCommand({ ...selfImpl, subcommands, parse, handle } as any) + // Service identity preserved; type widens from Input to NewInput + type NewService = ServiceMap.Service, NewInput> + + return makeCommand({ + name: impl.name, + config: impl.config, + description: impl.description, + service: impl.service as NewService, + subcommands, + parse, + handle + }) }) // Errors across a tuple (preferred), falling back to array element type @@ -674,7 +677,7 @@ export const provideSync: { selfImpl.handle(input, commandPath), service, typeof implementation === "function" - ? (implementation as any)(input) + ? (implementation as (input: Input) => S)(input) : implementation ) }) From 20c2c24beb2a0ff8a850cffdf8b87e8d80fb60e3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 20:35:58 -0500 Subject: [PATCH 14/29] refactor(cli): simplify error handling in runWith Remove UserError wrap/unwrap dance. Use Effect.catch with instanceof check to handle ShowHelp errors directly. --- packages/effect/src/unstable/cli/Command.ts | 24 +++++++-------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index f71f97121..de5a72a86 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -910,26 +910,18 @@ export const runWith = ( } const parsed = parseResult.success - // Create the execution program + // Create and run the execution program const program = commandImpl.handle(parsed, [command.name]) - - // Apply log level if provided via built-ins - const finalProgram = logLevel !== undefined + const withLogLevel = logLevel !== undefined ? Effect.provideService(program, References.MinimumLogLevel, logLevel) : program - // Normalize non-CLI errors into CliError.UserError so downstream catchTags - // can rely on CLI-tagged errors only. - const normalized = finalProgram.pipe( - Effect.catch((err) => - CliError.isCliError(err) ? Effect.fail(err) : Effect.fail(new CliError.UserError({ cause: err })) - ) - ) - - yield* normalized + yield* withLogLevel }, - Effect.catchTag("ShowHelp", (error: CliError.ShowHelp) => showHelp(command, error.commandPath)), - // Preserve prior public behavior: surface original handler errors - Effect.catchTag("UserError", (error: CliError.UserError) => Effect.fail(error.cause as E | CliError.CliError)) + Effect.catch((error) => + error instanceof CliError.ShowHelp + ? showHelp(command, error.commandPath) + : Effect.fail(error) + ) ) } From 8c5deec90881691f23e40e1c0b0434f4e846c4ab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 20:39:26 -0500 Subject: [PATCH 15/29] refactor(cli): clean up runWith structure - Extract commandPath computation once - Group built-in flag handlers together - Use return yield* for early exits - Consolidate parsing error handling --- packages/effect/src/unstable/cli/Command.ts | 43 ++++++++------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index de5a72a86..d67955ea1 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -867,46 +867,35 @@ export const runWith = ( return } - // Parse command arguments (built-ins are extracted automatically) + // Lex and extract built-in flags const { tokens, trailingOperands } = Lexer.lex(args) - const { - completions, - help, - logLevel, - remainder, - version - } = yield* Parser.extractBuiltInOptions(tokens) + const { completions, help, logLevel, remainder, version } = yield* Parser.extractBuiltInOptions(tokens) const parsedArgs = yield* Parser.parseArgs({ tokens: remainder, trailingOperands }, command) - const helpRenderer = yield* HelpFormatter.HelpRenderer + const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] as const + // Handle built-in flags (early exits) if (help) { - const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] - const helpDoc = getHelpForCommandPath(command, commandPath) - const helpText = helpRenderer.formatHelpDoc(helpDoc) - yield* Console.log(helpText) + const helpRenderer = yield* HelpFormatter.HelpRenderer + yield* Console.log(helpRenderer.formatHelpDoc(getHelpForCommandPath(command, commandPath))) return - } else if (completions !== undefined) { - const script = generateDynamicCompletion(command, command.name, completions) - yield* Console.log(script) + } + if (completions !== undefined) { + yield* Console.log(generateDynamicCompletion(command, command.name, completions)) return - } else if (version && command.subcommands.length === 0) { - const versionText = helpRenderer.formatVersion(command.name, config.version) - yield* Console.log(versionText) + } + if (version && command.subcommands.length === 0) { + const helpRenderer = yield* HelpFormatter.HelpRenderer + yield* Console.log(helpRenderer.formatVersion(command.name, config.version)) return } - // If there are parsing errors and no help was requested, format and display the error + // Handle parsing errors if (parsedArgs.errors && parsedArgs.errors.length > 0) { - const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] - yield* showHelp(command, commandPath, parsedArgs.errors[0]) - return + return yield* showHelp(command, commandPath, parsedArgs.errors[0]) } - const parseResult = yield* Effect.result(commandImpl.parse(parsedArgs)) if (parseResult._tag === "Failure") { - const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] - yield* showHelp(command, commandPath, parseResult.failure) - return + return yield* showHelp(command, commandPath, parseResult.failure) } const parsed = parseResult.success From e0949f3a90f67964c2bc138a4b43ac31ccd13925 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 20:40:34 -0500 Subject: [PATCH 16/29] refactor(cli): simplify type extractors using T[number] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace recursive conditional types with simpler T[number] pattern. 18 lines → 6 lines for the same functionality. --- packages/effect/src/unstable/cli/Command.ts | 25 +++++---------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index d67955ea1..d9ca30473 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -551,25 +551,12 @@ export const withSubcommands: { }) }) -// Errors across a tuple (preferred), falling back to array element type -type ExtractSubcommandErrors> = T extends readonly [] ? never - : T extends readonly [infer H, ...infer R] ? Error | ExtractSubcommandErrors - : T extends ReadonlyArray ? Error - : never - -type ContextOf = C extends Command ? R : never - -type ExtractSubcommandContext> = T extends readonly [] ? never - : T extends readonly [infer H, ...infer R] ? ContextOf | ExtractSubcommandContext - : T extends ReadonlyArray ? ContextOf - : never - -type InputOf = C extends Command ? { readonly name: N; readonly result: I } : never - -type ExtractSubcommandInputs> = T extends readonly [] ? never - : T extends readonly [infer H, ...infer R] ? InputOf | ExtractSubcommandInputs - : T extends ReadonlyArray ? InputOf - : never +// Type extractors for subcommand arrays - T[number] gives union of all elements +type ExtractSubcommandErrors>> = Error +type ExtractSubcommandContext>> = T[number] extends + Command ? R : never +type ExtractSubcommandInputs>> = T[number] extends + Command ? { readonly name: N; readonly result: I } : never /** * Sets the description for a command. From 0c0a4f40e1cd35ac712f4c2abcf13319b86d3ac2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 20:47:08 -0500 Subject: [PATCH 17/29] refactor(cli): miscellaneous Command.ts cleanups - Fix withHandler JSDoc to use array syntax for withSubcommands - Remove redundant args variable in runWith - Yield HelpRenderer once at start of built-in handling - Extract mapHandler helper to consolidate provide* functions --- packages/effect/src/unstable/cli/Command.ts | 81 +++++++-------------- 1 file changed, 28 insertions(+), 53 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index d9ca30473..403988d9d 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -383,7 +383,7 @@ export const prompt = ( * const git = Command.make("git", { * verbose: Flag.boolean("verbose") * }).pipe( - * Command.withSubcommands(clone, add), + * Command.withSubcommands([clone, add]), * Command.withHandler((config) => * Effect.gen(function*() { * // Now config has the subcommand field @@ -600,6 +600,15 @@ export const withDescription: { /* Providing Services */ /* ========================================================================== */ +// Internal helper: transforms a command's handler while preserving other properties +const mapHandler = ( + self: Command, + f: (handler: Effect.Effect, input: Input) => Effect.Effect +) => { + const impl = toImpl(self) + return makeCommand({ ...impl, handle: (input, path) => f(impl.handle(input, path), input) }) +} + /** * Provides the handler of a command with the services produced by a layer * that optionally depends on the command-line input to be created. @@ -620,17 +629,7 @@ export const provide: { } = dual(2, ( self: Command, layer: Layer.Layer | ((input: Input) => Layer.Layer) -) => { - const selfImpl = toImpl(self) - return makeCommand({ - ...selfImpl, - handle: (input, commandPath) => - Effect.provide( - selfImpl.handle(input, commandPath), - typeof layer === "function" ? layer(input) : layer - ) - }) -}) +) => mapHandler(self, (handler, input) => Effect.provide(handler, typeof layer === "function" ? layer(input) : layer))) /** * Provides the handler of a command with the implementation of a service that @@ -655,20 +654,13 @@ export const provideSync: { self: Command, service: ServiceMap.Service, implementation: S | ((input: Input) => S) -) => { - const selfImpl = toImpl(self) - return makeCommand({ - ...selfImpl, - handle: (input, commandPath) => - Effect.provideService( - selfImpl.handle(input, commandPath), - service, - typeof implementation === "function" - ? (implementation as (input: Input) => S)(input) - : implementation - ) - }) -}) +) => + mapHandler(self, (handler, input) => + Effect.provideService( + handler, + service, + typeof implementation === "function" ? (implementation as (input: Input) => S)(input) : implementation + ))) /** * Provides the handler of a command with the service produced by an effect @@ -693,18 +685,12 @@ export const provideEffect: { self: Command, service: ServiceMap.Service, effect: Effect.Effect | ((input: Input) => Effect.Effect) -) => { - const selfImpl = toImpl(self) - return makeCommand({ - ...selfImpl, - handle: (input, commandPath) => - Effect.provideServiceEffect( - selfImpl.handle(input, commandPath), - service, - typeof effect === "function" ? effect(input) : effect - ) - }) -}) +) => + mapHandler( + self, + (handler, input) => + Effect.provideServiceEffect(handler, service, typeof effect === "function" ? effect(input) : effect) + )) /** * Allows for execution of an effect, which optionally depends on command-line @@ -726,17 +712,8 @@ export const provideEffectDiscard: { } = dual(2, ( self: Command, effect: Effect.Effect<_, E2, R2> | ((input: Input) => Effect.Effect<_, E2, R2>) -) => { - const selfImpl = toImpl(self) - return makeCommand({ - ...selfImpl, - handle: (input, commandPath) => - Effect.andThen( - typeof effect === "function" ? effect(input) : effect, - selfImpl.handle(input, commandPath) - ) - }) -}) +) => + mapHandler(self, (handler, input) => Effect.andThen(typeof effect === "function" ? effect(input) : effect, handler))) /* ========================================================================== */ /* Execution */ @@ -846,8 +823,7 @@ export const runWith = ( ): (input: ReadonlyArray) => Effect.Effect => { const commandImpl = toImpl(command) return Effect.fnUntraced( - function*(input: ReadonlyArray) { - const args = input + function*(args: ReadonlyArray) { // Check for dynamic completion request early (before normal parsing) if (isCompletionRequest(args)) { handleCompletionRequest(command) @@ -859,10 +835,10 @@ export const runWith = ( const { completions, help, logLevel, remainder, version } = yield* Parser.extractBuiltInOptions(tokens) const parsedArgs = yield* Parser.parseArgs({ tokens: remainder, trailingOperands }, command) const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] as const + const helpRenderer = yield* HelpFormatter.HelpRenderer // Handle built-in flags (early exits) if (help) { - const helpRenderer = yield* HelpFormatter.HelpRenderer yield* Console.log(helpRenderer.formatHelpDoc(getHelpForCommandPath(command, commandPath))) return } @@ -871,7 +847,6 @@ export const runWith = ( return } if (version && command.subcommands.length === 0) { - const helpRenderer = yield* HelpFormatter.HelpRenderer yield* Console.log(helpRenderer.formatVersion(command.name, config.version)) return } From b4c166c8c9220d11bc4743eb23edf08d5e3adb48 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 21:17:41 -0500 Subject: [PATCH 18/29] refactor(cli): rename HelpFormatter to CliOutput, improve type names - HelpFormatter -> CliOutput (formats help, errors, version) - HelpRenderer -> Formatter (service interface) - defaultHelpRenderer -> defaultFormatter - RawInput -> ParsedTokens (clearer purpose) - ParentCommand -> CommandContext (describes service role) --- .../cli/{HelpFormatter.ts => CliOutput.ts} | 118 +++++++++--------- packages/effect/src/unstable/cli/Command.ts | 99 +++++++-------- packages/effect/src/unstable/cli/SEMANTICS.md | 19 ++- packages/effect/src/unstable/cli/index.ts | 2 +- .../src/unstable/cli/internal/command.ts | 14 +-- .../src/unstable/cli/internal/parser.ts | 6 +- .../test/unstable/cli/Arguments.test.ts | 8 +- .../effect/test/unstable/cli/Command.test.ts | 32 ++++- .../effect/test/unstable/cli/Help.test.ts | 8 +- .../effect/test/unstable/cli/LogLevel.test.ts | 8 +- 10 files changed, 171 insertions(+), 143 deletions(-) rename packages/effect/src/unstable/cli/{HelpFormatter.ts => CliOutput.ts} (73%) diff --git a/packages/effect/src/unstable/cli/HelpFormatter.ts b/packages/effect/src/unstable/cli/CliOutput.ts similarity index 73% rename from packages/effect/src/unstable/cli/HelpFormatter.ts rename to packages/effect/src/unstable/cli/CliOutput.ts index 70462981c..15c432503 100644 --- a/packages/effect/src/unstable/cli/HelpFormatter.ts +++ b/packages/effect/src/unstable/cli/CliOutput.ts @@ -8,42 +8,42 @@ import type * as CliError from "./CliError.ts" import type { HelpDoc } from "./HelpDoc.ts" /** - * Service interface for rendering help documentation into formatted text. - * This allows customization of help output formatting, including color support. + * Service interface for formatting CLI output including help, errors, and version info. + * This allows customization of output formatting, including color support. * * @example * ```ts * import { Effect } from "effect" - * import { HelpFormatter } from "effect/unstable/cli" + * import { CliOutput } from "effect/unstable/cli" * - * // Create a custom renderer implementation - * const customRenderer: HelpFormatter.HelpRenderer = { + * // Create a custom formatter implementation + * const customFormatter: CliOutput.Formatter = { * formatHelpDoc: (doc) => `Custom Help: ${doc.usage}`, * formatCliError: (error) => `Error: ${error.message}`, * formatError: (error) => `[ERROR] ${error.message}`, * formatVersion: (name, version) => `${name} (${version})` * } * - * // Use the custom renderer in a program + * // Use the custom formatter in a program * const program = Effect.gen(function*() { - * const renderer = yield* HelpFormatter.HelpRenderer - * const helpText = renderer.formatVersion("myapp", "1.0.0") + * const formatter = yield* CliOutput.Formatter + * const helpText = formatter.formatVersion("myapp", "1.0.0") * console.log(helpText) * }).pipe( - * Effect.provide(HelpFormatter.layer(customRenderer)) + * Effect.provide(CliOutput.layer(customFormatter)) * ) * ``` * * @since 4.0.0 * @category models */ -export interface HelpRenderer { +export interface Formatter { /** * Formats a HelpDoc structure into a readable string format. * * @example * ```ts - * import { HelpFormatter, HelpDoc } from "effect/unstable/cli" + * import { CliOutput, HelpDoc } from "effect/unstable/cli" * * const helpDoc: HelpDoc = { * usage: "myapp [options] ", @@ -68,8 +68,8 @@ export interface HelpRenderer { * ] * } * - * const renderer = HelpFormatter.defaultHelpRenderer() - * const helpText = renderer.formatHelpDoc(helpDoc) + * const formatter = CliOutput.defaultFormatter() + * const helpText = formatter.formatHelpDoc(helpDoc) * console.log(helpText) * // Outputs formatted help with sections: DESCRIPTION, USAGE, ARGUMENTS, FLAGS * ``` @@ -83,16 +83,16 @@ export interface HelpRenderer { * * @example * ```ts - * import { HelpFormatter, CliError } from "effect/unstable/cli" + * import { CliOutput, CliError } from "effect/unstable/cli" * import * as Data from "effect/Data" * * class InvalidOption extends Data.TaggedError("InvalidOption")<{ * readonly message: string * }> {} * - * const renderer = HelpFormatter.defaultHelpRenderer() + * const formatter = CliOutput.defaultFormatter() * const error = new InvalidOption({ message: "Unknown flag '--invalid'" }) - * const errorMessage = renderer.formatCliError(error) + * const errorMessage = formatter.formatCliError(error) * console.log(errorMessage) // "Unknown flag '--invalid'" * ``` * @@ -105,22 +105,22 @@ export interface HelpRenderer { * * @example * ```ts - * import { HelpFormatter, CliError } from "effect/unstable/cli" + * import { CliOutput, CliError } from "effect/unstable/cli" * import * as Data from "effect/Data" * * class ValidationError extends Data.TaggedError("ValidationError")<{ * readonly message: string * }> {} * - * const colorRenderer = HelpFormatter.defaultHelpRenderer({ colors: true }) - * const noColorRenderer = HelpFormatter.defaultHelpRenderer({ colors: false }) + * const colorFormatter = CliOutput.defaultFormatter({ colors: true }) + * const noColorFormatter = CliOutput.defaultFormatter({ colors: false }) * * const error = new ValidationError({ message: "Value must be positive" }) * - * const coloredError = colorRenderer.formatError(error) + * const coloredError = colorFormatter.formatError(error) * console.log(coloredError) // "\n\x1b[1m\x1b[31mERROR\x1b[0m\n Value must be positive\x1b[0m" * - * const plainError = noColorRenderer.formatError(error) + * const plainError = noColorFormatter.formatError(error) * console.log(plainError) // "\nERROR\n Value must be positive" * ``` * @@ -133,18 +133,18 @@ export interface HelpRenderer { * * @example * ```ts - * import { HelpFormatter } from "effect/unstable/cli" + * import { CliOutput } from "effect/unstable/cli" * - * const colorRenderer = HelpFormatter.defaultHelpRenderer({ colors: true }) - * const noColorRenderer = HelpFormatter.defaultHelpRenderer({ colors: false }) + * const colorFormatter = CliOutput.defaultFormatter({ colors: true }) + * const noColorFormatter = CliOutput.defaultFormatter({ colors: false }) * * const appName = "my-awesome-tool" * const version = "1.2.3" * - * const coloredVersion = colorRenderer.formatVersion(appName, version) + * const coloredVersion = colorFormatter.formatVersion(appName, version) * console.log(coloredVersion) // "\x1b[1mmy-awesome-tool\x1b[0m \x1b[2mv\x1b[0m\x1b[1m1.2.3\x1b[0m" * - * const plainVersion = noColorRenderer.formatVersion(appName, version) + * const plainVersion = noColorFormatter.formatVersion(appName, version) * console.log(plainVersion) // "my-awesome-tool v1.2.3" * ``` * @@ -154,93 +154,93 @@ export interface HelpRenderer { } /** - * Service reference for the help renderer. Provides a default implementation + * Service reference for the CLI output formatter. Provides a default implementation * that can be overridden for custom formatting or testing. * * @example * ```ts - * import { HelpFormatter } from "effect/unstable/cli" + * import { CliOutput } from "effect/unstable/cli" * import * as Effect from "effect/Effect" * - * // Access the help renderer service + * // Access the formatter service * const program = Effect.gen(function* () { - * const renderer = yield* HelpFormatter.HelpRenderer + * const formatter = yield* CliOutput.Formatter * * // Format version information - * const versionText = renderer.formatVersion("my-cli", "2.1.0") + * const versionText = formatter.formatVersion("my-cli", "2.1.0") * console.log(versionText) // "my-cli v2.1.0" (with colors if supported) * * return versionText * }) * - * // Run with default renderer + * // Run with default formatter * const result = Effect.runSync(program) * ``` * * @since 4.0.0 * @category services */ -export const HelpRenderer: ServiceMap.Reference = ServiceMap.Reference( - "effect/cli/HelpRenderer", - { defaultValue: () => defaultHelpRenderer() } +export const Formatter: ServiceMap.Reference = ServiceMap.Reference( + "effect/cli/CliOutput", + { defaultValue: () => defaultFormatter() } ) /** - * Creates a Layer that provides a custom HelpRenderer implementation. + * Creates a Layer that provides a custom Formatter implementation. * * @example * ```ts - * import { HelpFormatter } from "effect/unstable/cli" + * import { CliOutput } from "effect/unstable/cli" * import * as Effect from "effect/Effect" * import * as Console from "effect/Console" * - * // Create a custom renderer without colors - * const noColorRenderer = HelpFormatter.defaultHelpRenderer({ colors: false }) - * const NoColorLayer = HelpFormatter.layer(noColorRenderer) + * // Create a custom formatter without colors + * const noColorFormatter = CliOutput.defaultFormatter({ colors: false }) + * const NoColorLayer = CliOutput.layer(noColorFormatter) * - * // Create a program that uses the custom help renderer + * // Create a program that uses the custom formatter * const program = Effect.gen(function* () { - * const renderer = yield* HelpFormatter.HelpRenderer - * const versionText = renderer.formatVersion("my-cli", "1.0.0") - * yield* Console.log(`Using custom renderer: ${versionText}`) + * const formatter = yield* CliOutput.Formatter + * const versionText = formatter.formatVersion("my-cli", "1.0.0") + * yield* Console.log(`Using custom formatter: ${versionText}`) * }).pipe( * Effect.provide(NoColorLayer) * ) * - * // You can also create completely custom renderers - * const jsonRenderer: HelpFormatter.HelpRenderer = { + * // You can also create completely custom formatters + * const jsonFormatter: CliOutput.Formatter = { * formatHelpDoc: (doc) => JSON.stringify(doc, null, 2), * formatCliError: (error) => JSON.stringify({ error: error.message }), * formatError: (error) => JSON.stringify({ type: "error", message: error.message }), * formatVersion: (name, version) => JSON.stringify({ name, version }) * } - * const JsonLayer = HelpFormatter.layer(jsonRenderer) + * const JsonLayer = CliOutput.layer(jsonFormatter) * ``` * * @since 4.0.0 * @category layers */ -export const layer = (renderer: HelpRenderer): Layer.Layer => Layer.succeed(HelpRenderer)(renderer) +export const layer = (formatter: Formatter): Layer.Layer => Layer.succeed(Formatter)(formatter) /** - * Creates a default help renderer with configurable options. + * Creates a default formatter with configurable options. * * @example * ```ts * import { Effect } from "effect" - * import { CliError, HelpFormatter } from "effect/unstable/cli" + * import { CliError, CliOutput } from "effect/unstable/cli" * - * // Create a renderer without colors for tests or CI environments - * const noColorRenderer = HelpFormatter.defaultHelpRenderer({ colors: false }) + * // Create a formatter without colors for tests or CI environments + * const noColorFormatter = CliOutput.defaultFormatter({ colors: false }) * - * // Create a renderer with colors forced on - * const colorRenderer = HelpFormatter.defaultHelpRenderer({ colors: true }) + * // Create a formatter with colors forced on + * const colorFormatter = CliOutput.defaultFormatter({ colors: true }) * * // Auto-detect colors based on terminal support (default behavior) - * const autoRenderer = HelpFormatter.defaultHelpRenderer() + * const autoFormatter = CliOutput.defaultFormatter() * * const program = Effect.gen(function*() { - * const renderer = colorRenderer + * const formatter = colorFormatter * * // Format an error with proper styling * const error = new CliError.InvalidValue({ @@ -248,11 +248,11 @@ export const layer = (renderer: HelpRenderer): Layer.Layer => Layer.succe * value: "bar", * expected: "baz" * }) - * const errorText = renderer.formatError(error) + * const errorText = formatter.formatError(error) * console.log(errorText) * * // Format version information - * const versionText = renderer.formatVersion("my-tool", "1.2.3") + * const versionText = formatter.formatVersion("my-tool", "1.2.3") * console.log(versionText) * }) * ``` @@ -260,7 +260,7 @@ export const layer = (renderer: HelpRenderer): Layer.Layer => Layer.succe * @since 4.0.0 * @category constructors */ -export const defaultHelpRenderer = (options?: { colors?: boolean }): HelpRenderer => { +export const defaultFormatter = (options?: { colors?: boolean }): Formatter => { const globalProcess = (globalThis as any).process const hasProcess = typeof globalProcess === "object" && globalProcess !== null diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 403988d9d..ebbc58b6b 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -14,7 +14,7 @@ import * as References from "../../References.ts" import type * as ServiceMap from "../../ServiceMap.ts" import type { Simplify } from "../../types/Types.ts" import * as CliError from "./CliError.ts" -import * as HelpFormatter from "./HelpFormatter.ts" +import * as CliOutput from "./CliOutput.ts" import { checkForDuplicateFlags, getHelpForCommandPath, makeCommand, toImpl, type TypeId } from "./internal/command.ts" import { generateDynamicCompletion, @@ -82,7 +82,7 @@ export interface Command exten Command, Input, never, - ParentCommand + CommandContext > { readonly [TypeId]: typeof TypeId @@ -226,25 +226,24 @@ export type Error = C extends Command< * @since 4.0.0 * @category models */ -export interface ParentCommand { +export interface CommandContext { readonly _: unique symbol readonly name: Name } /** - * Represents the raw input parsed from the command-line which is provided to - * the `Command.parse` method. + * Represents the parsed tokens from command-line input before validation. * * @since 4.0.0 * @category models */ -export interface RawInput { +export interface ParsedTokens { readonly flags: Record> readonly arguments: ReadonlyArray readonly errors?: ReadonlyArray readonly subcommand?: { readonly name: string - readonly parsedInput: RawInput + readonly parsedInput: ParsedTokens } } @@ -460,9 +459,9 @@ export const withSubcommands: { self: Command ) => Command< Name, - Input & { readonly subcommand: ExtractSubcommandInputs | undefined }, - ExtractSubcommandErrors, - R | Exclude, ParentCommand> + Input, + E | ExtractSubcommandErrors, + R | Exclude, CommandContext> > < Name extends string, @@ -475,9 +474,9 @@ export const withSubcommands: { subcommands: Subcommands ): Command< Name, - Input & { readonly subcommand: ExtractSubcommandInputs | undefined }, - ExtractSubcommandErrors, - R | Exclude, ParentCommand> + Input, + E | ExtractSubcommandErrors, + R | Exclude, CommandContext> > } = dual(2, < Name extends string, @@ -490,61 +489,55 @@ export const withSubcommands: { subcommands: Subcommands ): Command< Name, - Input & { readonly subcommand: ExtractSubcommandInputs | undefined }, - ExtractSubcommandErrors, - R | Exclude, ParentCommand> + Input, + E | ExtractSubcommandErrors, + R | Exclude, CommandContext> > => { checkForDuplicateFlags(self, subcommands) const impl = toImpl(self) const byName = new Map(subcommands.map((s) => [s.name, toImpl(s)] as const)) - // Type aliases for the widened command signature - type Subcommand = ExtractSubcommandInputs - type NewInput = Input & { readonly subcommand: Subcommand | undefined } + // Internal type for routing - not exposed in public type + type SubcommandInfo = { readonly name: string; readonly result: unknown } + type InternalInput = Input & { readonly _subcommand?: SubcommandInfo } - // Extend parent parsing with subcommand resolution - // Cast needed: TS can't prove { ...parent, subcommand } satisfies NewInput - const parse: (raw: RawInput) => Effect.Effect = Effect.fnUntraced( - function*(raw: RawInput) { - const parent = yield* impl.parse(raw) + const parse = Effect.fnUntraced(function*(raw: ParsedTokens) { + const parent = yield* impl.parse(raw) - if (!raw.subcommand) { - return { ...parent, subcommand: undefined } as NewInput - } - - const sub = byName.get(raw.subcommand.name) - if (!sub) { - return { ...parent, subcommand: undefined } as NewInput - } + if (!raw.subcommand) { + return parent + } - const result = yield* sub.parse(raw.subcommand.parsedInput) - return { ...parent, subcommand: { name: sub.name, result } } as NewInput + const sub = byName.get(raw.subcommand.name) + if (!sub) { + return parent } - ) - // Route execution to subcommand handler or parent handler - const handle = Effect.fnUntraced(function*(input: NewInput, path: ReadonlyArray) { - if (input.subcommand) { - const child = byName.get(input.subcommand.name) + const result = yield* sub.parse(raw.subcommand.parsedInput) + // Attach subcommand info internally for routing + return Object.assign({}, parent, { _subcommand: { name: sub.name, result } }) as InternalInput + }) + + const handle = Effect.fnUntraced(function*(input: Input, path: ReadonlyArray) { + const internal = input as InternalInput + if (internal._subcommand) { + const child = byName.get(internal._subcommand.name) if (!child) { return yield* new CliError.ShowHelp({ commandPath: path }) } return yield* child - .handle(input.subcommand.result, [...path, child.name]) + .handle(internal._subcommand.result, [...path, child.name]) .pipe(Effect.provideService(impl.service, input)) } - return yield* impl.handle(input as Input, path) + return yield* impl.handle(input, path) }) - // Service identity preserved; type widens from Input to NewInput - type NewService = ServiceMap.Service, NewInput> - return makeCommand({ name: impl.name, config: impl.config, description: impl.description, - service: impl.service as NewService, + service: impl.service, subcommands, parse, handle @@ -555,8 +548,6 @@ export const withSubcommands: { type ExtractSubcommandErrors>> = Error type ExtractSubcommandContext>> = T[number] extends Command ? R : never -type ExtractSubcommandInputs>> = T[number] extends - Command ? { readonly name: N; readonly result: I } : never /** * Sets the description for a command. @@ -725,11 +716,11 @@ const showHelp = ( error?: CliError.CliError ): Effect.Effect => Effect.gen(function*() { - const helpRenderer = yield* HelpFormatter.HelpRenderer + const formatter = yield* CliOutput.Formatter const helpDoc = getHelpForCommandPath(command, commandPath) - yield* Console.log(helpRenderer.formatHelpDoc(helpDoc)) + yield* Console.log(formatter.formatHelpDoc(helpDoc)) if (error) { - yield* Console.error(helpRenderer.formatError(error)) + yield* Console.error(formatter.formatError(error)) } }) @@ -835,19 +826,19 @@ export const runWith = ( const { completions, help, logLevel, remainder, version } = yield* Parser.extractBuiltInOptions(tokens) const parsedArgs = yield* Parser.parseArgs({ tokens: remainder, trailingOperands }, command) const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] as const - const helpRenderer = yield* HelpFormatter.HelpRenderer + const formatter = yield* CliOutput.Formatter // Handle built-in flags (early exits) if (help) { - yield* Console.log(helpRenderer.formatHelpDoc(getHelpForCommandPath(command, commandPath))) + yield* Console.log(formatter.formatHelpDoc(getHelpForCommandPath(command, commandPath))) return } if (completions !== undefined) { yield* Console.log(generateDynamicCompletion(command, command.name, completions)) return } - if (version && command.subcommands.length === 0) { - yield* Console.log(helpRenderer.formatVersion(command.name, config.version)) + if (version) { + yield* Console.log(formatter.formatVersion(command.name, config.version)) return } diff --git a/packages/effect/src/unstable/cli/SEMANTICS.md b/packages/effect/src/unstable/cli/SEMANTICS.md index 252747459..4dbeafd9e 100644 --- a/packages/effect/src/unstable/cli/SEMANTICS.md +++ b/packages/effect/src/unstable/cli/SEMANTICS.md @@ -34,10 +34,14 @@ This file records the intended parsing semantics with a short usage example and Example: `tool env --env foo=bar --env cool=dude` → `{ foo: "bar", cool: "dude" }` Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should merge repeated key=value flags into a single record" -- **Parent context is accessible inside subcommands** - Example: `tool --global install --pkg cowsay` → subcommand can read `global` from parent context +- **Parent context is accessible inside subcommands** + Example: `tool --global install --pkg cowsay` → subcommand can read `global` from parent context Test: `packages/effect/test/unstable/cli/Command.test.ts` – "should allow direct accessing parent config in subcommands" +- **Built-in flags (`--version`, `--help`) take global precedence** + Example: `tool --version copy src dest` → prints version and exits; subcommand is not run + Tests: `packages/effect/test/unstable/cli/Command.test.ts` – "should print version and exit even with subcommands (global precedence)" and "should print version and exit when --version appears before subcommand" + If you add or change semantics, update this file and reference the exact test that proves the behavior. ## Semantics Landscape (popular CLIs vs Effect) @@ -107,10 +111,19 @@ Below each semantic you’ll find: a short description, a usage example, how maj - **Suggestion**: Keep; predictable and convenient. ### Required or mutually exclusive flag sets (validation, not parsing) -- **What**: Constraints like “must provide either --token or --user/--pass”, or “--json and --color cannot both be set”. +- **What**: Constraints like "must provide either --token or --user/--pass", or "--json and --color cannot both be set". - **Commander / yargs / clap**: Provide APIs for required/conflicts; Click supports required options and `mutually_exclusive` via groups; argparse has mutually exclusive groups. - **Effect (current)**: Not enforced at parser level. Would live in a validation layer if needed. - **Suggestion**: Stay out of parsing; add optional validators in configuration if/when a product needs it. +### Built-in flags (`--version`, `--help`) global precedence +- **What**: Whether `--version` and `--help` take precedence over subcommands and other arguments. +- **Example**: `tool --version install pkg` or `tool install --version pkg` +- **git / cargo (clap) / npm**: `--version` anywhere prints version and exits. Subcommand is ignored. +- **gh (Cobra)**: `--version` only works alone on root command. `gh --version pr` errors. +- **docker / bun**: Subcommand runs; `--version` on root is ignored if subcommand follows. +- **Effect (current)**: Global precedence (git/npm style). `--version` prints version and exits regardless of subcommands or position. +- **Suggestion**: Keep; this is the most user-friendly behavior. Users expect `--version` to work anywhere. + ## Opinionated default Effect should remain on the permissive, npm/commander-style side: flexible option placement, parent flags usable before or after subcommands, strict `--` handling, and single-shot subcommand selection on the first value. This keeps UX friendly for modern CLIs while remaining predictable via documented rules and tests. diff --git a/packages/effect/src/unstable/cli/index.ts b/packages/effect/src/unstable/cli/index.ts index a85fedeaa..5624c9cde 100644 --- a/packages/effect/src/unstable/cli/index.ts +++ b/packages/effect/src/unstable/cli/index.ts @@ -30,7 +30,7 @@ export * as HelpDoc from "./HelpDoc.ts" /** * @since 4.0.0 */ -export * as HelpFormatter from "./HelpFormatter.ts" +export * as CliOutput from "./CliOutput.ts" /** * @since 4.0.0 diff --git a/packages/effect/src/unstable/cli/internal/command.ts b/packages/effect/src/unstable/cli/internal/command.ts index 4f8935008..922992a14 100644 --- a/packages/effect/src/unstable/cli/internal/command.ts +++ b/packages/effect/src/unstable/cli/internal/command.ts @@ -20,7 +20,7 @@ import { type ConfigInternal, reconstructTree } from "./config.ts" /* Types */ /* ========================================================================== */ -import type { Command, Environment, ParentCommand, RawInput } from "../Command.ts" +import type { Command, CommandContext, Environment, ParsedTokens } from "../Command.ts" /** * Internal implementation interface with all the machinery. @@ -28,8 +28,8 @@ import type { Command, Environment, ParentCommand, RawInput } from "../Command.t */ export interface CommandInternal extends Command { readonly config: ConfigInternal - readonly service: ServiceMap.Service, Input> - readonly parse: (input: RawInput) => Effect.Effect + readonly service: ServiceMap.Service, Input> + readonly parse: (input: ParsedTokens) => Effect.Effect readonly handle: ( input: Input, commandPath: ReadonlyArray @@ -79,15 +79,15 @@ export const Proto = { export const makeCommand = (options: { readonly name: Name readonly config: ConfigInternal - readonly service?: ServiceMap.Service, Input> | undefined + readonly service?: ServiceMap.Service, Input> | undefined readonly description?: string | undefined readonly subcommands?: ReadonlyArray> | undefined - readonly parse?: ((input: RawInput) => Effect.Effect) | undefined + readonly parse?: ((input: ParsedTokens) => Effect.Effect) | undefined readonly handle?: | ((input: Input, commandPath: ReadonlyArray) => Effect.Effect) | undefined }): Command => { - const service = options.service ?? ServiceMap.Service, Input>(`${TypeId}/${options.name}`) + const service = options.service ?? ServiceMap.Service, Input>(`${TypeId}/${options.name}`) const config = options.config const handle = ( @@ -98,7 +98,7 @@ export const makeCommand = (options: { ? options.handle(input, commandPath) : Effect.fail(new CliError.ShowHelp({ commandPath })) - const parse = options.parse ?? Effect.fnUntraced(function*(input: RawInput) { + const parse = options.parse ?? Effect.fnUntraced(function*(input: ParsedTokens) { const parsedArgs: Param.ParsedArgs = { flags: input.flags, arguments: input.arguments } const values = yield* parseParams(parsedArgs, config.orderedParams) return reconstructTree(config.tree, values) as Input diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index d4adb3f3c..32596a6fc 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -30,7 +30,7 @@ import type { LogLevel } from "../../../LogLevel.ts" import type { FileSystem } from "../../../platform/FileSystem.ts" import type { Path } from "../../../platform/Path.ts" import * as CliError from "../CliError.ts" -import type { Command, RawInput } from "../Command.ts" +import type { Command, ParsedTokens } from "../Command.ts" import * as Param from "../Param.ts" import { isFalseValue, isTrueValue } from "../Primitive.ts" import { suggest } from "./auto-suggest.ts" @@ -43,7 +43,7 @@ import { type LexResult, type Token } from "./lexer.ts" /* ========================================================================== */ /** @internal */ -export const getCommandPath = (parsedInput: RawInput): ReadonlyArray => +export const getCommandPath = (parsedInput: ParsedTokens): ReadonlyArray => parsedInput.subcommand ? [parsedInput.subcommand.name, ...getCommandPath(parsedInput.subcommand.parsedInput)] : [] @@ -83,7 +83,7 @@ export const parseArgs = ( lexResult: LexResult, command: Command, commandPath: ReadonlyArray = [] -): Effect.Effect => +): Effect.Effect => Effect.gen(function*() { const { tokens, trailingOperands: afterEndOfOptions } = lexResult const newCommandPath = [...commandPath, command.name] diff --git a/packages/effect/test/unstable/cli/Arguments.test.ts b/packages/effect/test/unstable/cli/Arguments.test.ts index 6c6761283..e001bbf29 100644 --- a/packages/effect/test/unstable/cli/Arguments.test.ts +++ b/packages/effect/test/unstable/cli/Arguments.test.ts @@ -2,7 +2,7 @@ import { assert, describe, expect, it } from "@effect/vitest" import { Effect, Layer, Ref } from "effect" import { FileSystem, Path, PlatformError } from "effect/platform" import { TestConsole } from "effect/testing" -import { Argument, Command, Flag, HelpFormatter } from "effect/unstable/cli" +import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli" import * as MockTerminal from "./services/MockTerminal.ts" const ConsoleLayer = TestConsole.layer @@ -27,8 +27,8 @@ const FileSystemLayer = FileSystem.layerNoop({ }) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer -const HelpFormatterLayer = HelpFormatter.layer( - HelpFormatter.defaultHelpRenderer({ +const CliOutputLayer = CliOutput.layer( + CliOutput.defaultFormatter({ colors: false }) ) @@ -38,7 +38,7 @@ const TestLayer = Layer.mergeAll( FileSystemLayer, PathLayer, TerminalLayer, - HelpFormatterLayer + CliOutputLayer ) describe("Command arguments", () => { diff --git a/packages/effect/test/unstable/cli/Command.test.ts b/packages/effect/test/unstable/cli/Command.test.ts index b17c5e956..fa410f77c 100644 --- a/packages/effect/test/unstable/cli/Command.test.ts +++ b/packages/effect/test/unstable/cli/Command.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect" import { Option } from "effect/data" import { FileSystem, Path } from "effect/platform" import { TestConsole } from "effect/testing" -import { Argument, Command, Flag, HelpFormatter } from "effect/unstable/cli" +import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli" import * as Cli from "./fixtures/ComprehensiveCli.ts" import * as MockTerminal from "./services/MockTerminal.ts" import * as TestActions from "./services/TestActions.ts" @@ -13,8 +13,8 @@ const ConsoleLayer = TestConsole.layer const FileSystemLayer = FileSystem.layerNoop({}) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer -const HelpFormatterLayer = HelpFormatter.layer( - HelpFormatter.defaultHelpRenderer({ +const CliOutputLayer = CliOutput.layer( + CliOutput.defaultFormatter({ colors: false }) ) @@ -25,7 +25,7 @@ const TestLayer = Layer.mergeAll( FileSystemLayer, PathLayer, TerminalLayer, - HelpFormatterLayer + CliOutputLayer ) describe("Command", () => { @@ -790,5 +790,29 @@ describe("Command", () => { -q" `) }).pipe(Effect.provide(TestLayer))) + + it.effect("should print version and exit even with subcommands (global precedence)", () => + Effect.gen(function*() { + // --version should work on a command with subcommands + yield* Cli.run(["--version"]) + + const output = yield* TestConsole.logLines + const outputText = output.join("\n") + expect(outputText).toContain("1.0.0") + }).pipe(Effect.provide(TestLayer))) + + it.effect("should print version and exit when --version appears before subcommand", () => + Effect.gen(function*() { + // --version should take precedence over subcommand + yield* Cli.run(["--version", "copy", "src.txt", "dest.txt"]) + + const output = yield* TestConsole.logLines + const outputText = output.join("\n") + expect(outputText).toContain("1.0.0") + + // Subcommand should NOT have run + const actions = yield* TestActions.getActions + assert.strictEqual(actions.length, 0) + }).pipe(Effect.provide(TestLayer))) }) }) diff --git a/packages/effect/test/unstable/cli/Help.test.ts b/packages/effect/test/unstable/cli/Help.test.ts index bd197dcb1..aacede0db 100644 --- a/packages/effect/test/unstable/cli/Help.test.ts +++ b/packages/effect/test/unstable/cli/Help.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect, Layer } from "effect" import { FileSystem, Path } from "effect/platform" import { TestConsole } from "effect/testing" -import { HelpFormatter } from "effect/unstable/cli" +import { CliOutput } from "effect/unstable/cli" import * as Cli from "./fixtures/ComprehensiveCli.ts" import * as MockTerminal from "./services/MockTerminal.ts" import * as TestActions from "./services/TestActions.ts" @@ -12,8 +12,8 @@ const ConsoleLayer = TestConsole.layer const FileSystemLayer = FileSystem.layerNoop({}) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer -const HelpFormatterLayer = HelpFormatter.layer( - HelpFormatter.defaultHelpRenderer({ +const CliOutputLayer = CliOutput.layer( + CliOutput.defaultFormatter({ colors: false }) ) @@ -24,7 +24,7 @@ const TestLayer = Layer.mergeAll( FileSystemLayer, PathLayer, TerminalLayer, - HelpFormatterLayer + CliOutputLayer ) const runCommand = Effect.fnUntraced( diff --git a/packages/effect/test/unstable/cli/LogLevel.test.ts b/packages/effect/test/unstable/cli/LogLevel.test.ts index 7818ea7b2..4d3df1612 100644 --- a/packages/effect/test/unstable/cli/LogLevel.test.ts +++ b/packages/effect/test/unstable/cli/LogLevel.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Layer, Logger } from "effect" import { FileSystem, Path } from "effect/platform" -import { Command, Flag, HelpFormatter } from "effect/unstable/cli" +import { CliOutput, Command, Flag } from "effect/unstable/cli" import * as MockTerminal from "./services/MockTerminal.ts" // Create a test logger that captures log messages @@ -31,8 +31,8 @@ const makeTestLogger = () => { const FileSystemLayer = FileSystem.layerNoop({}) const PathLayer = Path.layer const TerminalLayer = MockTerminal.layer -const HelpFormatterLayer = HelpFormatter.layer( - HelpFormatter.defaultHelpRenderer({ +const CliOutputLayer = CliOutput.layer( + CliOutput.defaultFormatter({ colors: false }) ) @@ -42,7 +42,7 @@ const makeTestLayer = (testLogger: Logger.Logger) => FileSystemLayer, PathLayer, TerminalLayer, - HelpFormatterLayer, + CliOutputLayer, Logger.layer([testLogger]) ) From 5be1ca015a4c492d116a6e8ecb61ae225d5d8935 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 21:22:23 -0500 Subject: [PATCH 19/29] refactor(cli): simplify withSubcommands, remove type widening - Remove NewInput type widening from withSubcommands return type - Track subcommand info internally via _subcommand, not in public type - Eliminate 4 type casts (as NewInput, as Input, as NewService) - Fix tests to use yield* parent instead of yield* parent.service - Add TODO for process.argv browser compatibility - Simplify withHandler docstring example --- packages/effect/src/unstable/cli/Command.ts | 37 +++++-------------- .../effect/test/unstable/cli/Command.test.ts | 14 +++---- .../effect/test/unstable/cli/Errors.test.ts | 3 +- 3 files changed, 18 insertions(+), 36 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index ebbc58b6b..5d82603b3 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -361,36 +361,15 @@ export const prompt = ( * import { Command, Flag } from "effect/unstable/cli" * import { Effect, Console } from "effect" * - * // First define subcommands - * const clone = Command.make("clone", { - * repository: Flag.string("repository") - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Cloning ${config.repository}`) - * }) - * ) - * - * const add = Command.make("add", { - * files: Flag.string("files") - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Adding ${config.files}`) - * }) - * ) + * // Command without initial handler + * const greet = Command.make("greet", { + * name: Flag.string("name") + * }) * - * // Create main command with subcommands and handler - * const git = Command.make("git", { - * verbose: Flag.boolean("verbose") - * }).pipe( - * Command.withSubcommands([clone, add]), + * // Add handler later + * const greetWithHandler = greet.pipe( * Command.withHandler((config) => - * Effect.gen(function*() { - * // Now config has the subcommand field - * yield* Console.log(`Git verbose: ${config.verbose}`) - * if (config.subcommand) { - * yield* Console.log(`Executed subcommand: ${config.subcommand.name}`) - * } - * }) + * Console.log(`Hello, ${config.name}!`) * ) * ) * ``` @@ -762,6 +741,8 @@ export const run: { readonly version: string } ) => { + // TODO: process.argv is a Node.js global. For browser/edge runtime support, + // consider accepting an optional args parameter or using a platform service. const input = process.argv.slice(2) return runWith(command, config)(input) } diff --git a/packages/effect/test/unstable/cli/Command.test.ts b/packages/effect/test/unstable/cli/Command.test.ts index fa410f77c..2771b32e6 100644 --- a/packages/effect/test/unstable/cli/Command.test.ts +++ b/packages/effect/test/unstable/cli/Command.test.ts @@ -341,8 +341,8 @@ describe("Command", () => { // Create subcommand that accesses parent context const child = Command.make("child", { action: Flag.string("action") }, (config) => Effect.gen(function*() { - // Access parent config via the auto-generated tag - const parentConfig = yield* parent.service + // Access parent config by yielding the parent command + const parentConfig = yield* parent messages.push(`child: parent.verbose=${parentConfig.verbose}`) messages.push(`child: parent.config=${parentConfig.config}`) messages.push(`child: action=${config.action}`) @@ -386,7 +386,7 @@ describe("Command", () => { pkg: Flag.string("pkg") }, (config) => Effect.gen(function*() { - const parentConfig = yield* root.service + const parentConfig = yield* root messages.push(`install: global=${parentConfig.global}, pkg=${config.pkg}`) })) @@ -452,7 +452,7 @@ describe("Command", () => { name: Flag.string("name") }, (config) => Effect.gen(function*() { - const rootConfig = yield* root.service + const rootConfig = yield* root messages.push(`service: root.env=${rootConfig.env}`) messages.push(`service: name=${config.name}`) })) @@ -462,8 +462,8 @@ describe("Command", () => { targetVersion: Flag.string("target-version") }, (config) => Effect.gen(function*() { - const rootConfig = yield* root.service - const serviceConfig = yield* service.service + const rootConfig = yield* root + const serviceConfig = yield* service messages.push(`deploy: root.env=${rootConfig.env}`) messages.push(`deploy: service.name=${serviceConfig.name}`) messages.push(`deploy: target-version=${config.targetVersion}`) @@ -515,7 +515,7 @@ describe("Command", () => { targetVersion: Flag.string("target-version") }, (config) => Effect.gen(function*() { - const parentConfig = yield* parent.service + const parentConfig = yield* parent messages.push(`deploy: parent.verbose=${parentConfig.verbose}`) messages.push(`deploy: target-version=${config.targetVersion}`) })) diff --git a/packages/effect/test/unstable/cli/Errors.test.ts b/packages/effect/test/unstable/cli/Errors.test.ts index a24da20e9..d437042bb 100644 --- a/packages/effect/test/unstable/cli/Errors.test.ts +++ b/packages/effect/test/unstable/cli/Errors.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Layer } from "effect" import { FileSystem, Path } from "effect/platform" import { CliError, Command, Flag } from "effect/unstable/cli" +import { toImpl } from "effect/unstable/cli/Command" import * as Lexer from "effect/unstable/cli/internal/lexer" import * as Parser from "effect/unstable/cli/internal/parser" import * as MockTerminal from "./services/MockTerminal.ts" @@ -25,7 +26,7 @@ describe("Command errors", () => { }) const parsedInput = yield* Parser.parseArgs(Lexer.lex([]), command) - const error = yield* Effect.flip(command.parse(parsedInput)) + const error = yield* Effect.flip(toImpl(command).parse(parsedInput)) assert.instanceOf(error, CliError.MissingOption) assert.strictEqual(error.option, "value") }).pipe(Effect.provide(TestLayer))) From d88a250a88e48213a2eb5515b7669b269db86752 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 25 Nov 2025 21:25:39 -0500 Subject: [PATCH 20/29] docs(cli): improve Command.ts docstrings - CommandContext: explain how to access parent config via yield* - withSubcommands: show parent context access pattern - prompt: add docstring with example - provide: add example showing input-dependent layer --- packages/effect/src/unstable/cli/Command.ts | 110 ++++++++++++++++---- 1 file changed, 90 insertions(+), 20 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 5d82603b3..3f4d9b820 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -221,7 +221,38 @@ export type Error = C extends Command< never /** - * Service context for a specific command, providing access to command input through Effect's service system. + * Service context for a specific command, enabling subcommands to access their parent's parsed configuration. + * + * When a subcommand handler needs access to flags or arguments from a parent command, + * it can yield the parent command directly to retrieve its config. This is powered by + * Effect's service system - each command automatically creates a service that provides + * its parsed input to child commands. + * + * @example + * ```ts + * import { Command, Flag } from "effect/unstable/cli" + * import { Effect, Console } from "effect" + * + * const parent = Command.make("app", { + * verbose: Flag.boolean("verbose"), + * config: Flag.string("config") + * }) + * + * const child = Command.make("deploy", { + * target: Flag.string("target") + * }, (config) => + * Effect.gen(function*() { + * // Access parent's config by yielding the parent command + * const parentConfig = yield* parent + * yield* Console.log(`Verbose: ${parentConfig.verbose}`) + * yield* Console.log(`Config: ${parentConfig.config}`) + * yield* Console.log(`Target: ${config.target}`) + * }) + * ) + * + * const app = parent.pipe(Command.withSubcommands([child])) + * // Usage: app --verbose --config prod.json deploy --target staging + * ``` * * @since 4.0.0 * @category models @@ -330,6 +361,24 @@ export const make: { }) as any /** + * Creates a command that prompts the user for input using an interactive prompt. + * + * This is useful for commands that need to gather information interactively, + * such as wizards or setup flows. The prompt runs before the handler and its + * result is passed to the handler function. + * + * @example + * ```ts + * import { Command, Prompt } from "effect/unstable/cli" + * import { Effect, Console } from "effect" + * + * const setup = Command.prompt( + * "setup", + * Prompt.text({ message: "Enter your name:" }), + * (name) => Console.log(`Hello, ${name}!`) + * ) + * ``` + * * @since 4.0.0 * @category constructors */ @@ -395,37 +444,35 @@ export const withHandler: { /** * Adds subcommands to a command, creating a hierarchical command structure. * + * Subcommands can access their parent's parsed configuration by yielding the parent + * command within their handler. This enables patterns like global flags that affect + * all subcommands. + * * @example * ```ts * import { Command, Flag } from "effect/unstable/cli" * import { Effect, Console } from "effect" * + * // Parent command with global flags + * const git = Command.make("git", { + * verbose: Flag.boolean("verbose") + * }) + * + * // Subcommand that accesses parent config * const clone = Command.make("clone", { - * repository: Flag.string("repository") + * repository: Flag.string("repo") * }, (config) => * Effect.gen(function*() { + * const parent = yield* git // Access parent's parsed config + * if (parent.verbose) { + * yield* Console.log("Verbose mode enabled") + * } * yield* Console.log(`Cloning ${config.repository}`) * }) * ) * - * const add = Command.make("add", { - * files: Flag.string("files") - * }, (config) => - * Effect.gen(function*() { - * yield* Console.log(`Adding ${config.files}`) - * }) - * ) - * - * // Data-last (pipeable) - * const git = Command.make("git", {}, () => Effect.void).pipe( - * Command.withSubcommands([clone, add]) - * ) - * - * // Data-first - * const git2 = Command.withSubcommands( - * Command.make("git", {}, () => Effect.void), - * [clone, add] - * ) + * const app = git.pipe(Command.withSubcommands([clone])) + * // Usage: git --verbose clone --repo github.com/foo/bar * ``` * * @since 4.0.0 @@ -583,6 +630,29 @@ const mapHandler = ( * Provides the handler of a command with the services produced by a layer * that optionally depends on the command-line input to be created. * + * @example + * ```ts + * import { Command, Flag } from "effect/unstable/cli" + * import { Effect, Layer } from "effect" + * import { FileSystem } from "effect/platform" + * + * const deploy = Command.make("deploy", { + * env: Flag.string("env") + * }, (config) => + * Effect.gen(function*() { + * const fs = yield* FileSystem.FileSystem + * // Use fs... + * }) + * ).pipe( + * // Provide FileSystem based on the --env flag + * Command.provide((config) => + * config.env === "local" + * ? FileSystem.layerNoop({}) + * : FileSystem.layer + * ) + * ) + * ``` + * * @since 4.0.0 * @category providing services */ From 319125b93c2f24b455a52ea4c38f995b926e36d3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Nov 2025 07:52:33 -0500 Subject: [PATCH 21/29] refactor(cli): make Param internal, fix bugs, add missing Argument combinators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make Param module internal (not exported from index.ts) - Fix Flag.none passing string instead of Param.Flag constant - Rename fileString to fileText for consistency - Fix @since tags in Flag.ts (1.0.0 → 4.0.0) - Fix mapTryCatch using hardcoded "unknown" for option name - Add missing combinators to Argument: choiceWithValue, withPseudoName, filter, filterMap, orElse, orElseResult - Fix optional @category: constructors → combinators - Add architecture documentation to Param.ts --- packages/effect/src/unstable/cli/Argument.ts | 150 ++++++++++++++++++- packages/effect/src/unstable/cli/Flag.ts | 40 ++--- packages/effect/src/unstable/cli/Param.ts | 93 +++++++----- packages/effect/src/unstable/cli/index.ts | 5 - 4 files changed, 220 insertions(+), 68 deletions(-) diff --git a/packages/effect/src/unstable/cli/Argument.ts b/packages/effect/src/unstable/cli/Argument.ts index 62a27e769..1a6686b05 100644 --- a/packages/effect/src/unstable/cli/Argument.ts +++ b/packages/effect/src/unstable/cli/Argument.ts @@ -3,8 +3,9 @@ */ import type * as Option from "../../data/Option.ts" import type * as Redacted from "../../data/Redacted.ts" +import type * as Result from "../../data/Result.ts" import type * as Effect from "../../Effect.ts" -import { dual } from "../../Function.ts" +import { dual, type LazyArg } from "../../Function.ts" import type * as FileSystem from "../../platform/FileSystem.ts" import type * as Path from "../../platform/Path.ts" import type * as Schema from "../../schema/Schema.ts" @@ -16,6 +17,11 @@ import type * as Primitive from "./Primitive.ts" /** * Represents a positional command-line argument. * + * Note: `boolean` is intentionally omitted from Argument constructors. + * Positional boolean arguments are ambiguous in CLI design since there's + * no flag name to negate (e.g., `--no-verbose`). Use Flag.boolean instead, + * or use Argument.choice with explicit "true"/"false" strings if needed. + * * @since 4.0.0 * @category models */ @@ -180,7 +186,7 @@ export const redacted = (name: string): Argument> => P * @since 4.0.0 * @category constructors */ -export const fileText = (name: string): Argument => Param.fileString(Param.Argument, name) +export const fileText = (name: string): Argument => Param.fileText(Param.Argument, name) /** * Creates a positional argument that reads and validates file content using a schema. @@ -498,3 +504,143 @@ export const withSchema: { (schema: Schema.Codec): (self: Argument) => Argument (self: Argument, schema: Schema.Codec): Argument } = dual(2, (self: Argument, schema: Schema.Codec) => Param.withSchema(self, schema)) + +/** + * Creates a positional choice argument with custom value mapping. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const logLevel = Argument.choiceWithValue("level", [ + * ["debug", 0], + * ["info", 1], + * ["warn", 2], + * ["error", 3] + * ]) + * ``` + * + * @since 4.0.0 + * @category constructors + */ +export const choiceWithValue = >( + name: string, + choices: Choices +): Argument => Param.choiceWithValue(Param.Argument, name, choices) + +/** + * Sets a custom display name for the argument type in help documentation. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const port = Argument.integer("port").pipe( + * Argument.withPseudoName("PORT") + * ) + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const withPseudoName: { + (pseudoName: string): (self: Argument) => Argument + (self: Argument, pseudoName: string): Argument +} = dual(2, (self: Argument, pseudoName: string) => Param.withPseudoName(self, pseudoName)) + +/** + * Filters parsed values, failing with a custom error message if the predicate returns false. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const positiveInt = Argument.integer("count").pipe( + * Argument.filter( + * n => n > 0, + * n => `Expected positive integer, got ${n}` + * ) + * ) + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const filter: { + (predicate: (a: A) => boolean, onFalse: (a: A) => string): (self: Argument) => Argument + (self: Argument, predicate: (a: A) => boolean, onFalse: (a: A) => string): Argument +} = dual(3, ( + self: Argument, + predicate: (a: A) => boolean, + onFalse: (a: A) => string +) => Param.filter(self, predicate, onFalse)) + +/** + * Filters and transforms parsed values, failing with a custom error message + * if the filter function returns None. + * + * @example + * ```ts + * import { Option } from "effect/data" + * import { Argument } from "effect/unstable/cli" + * + * const positiveInt = Argument.integer("count").pipe( + * Argument.filterMap( + * (n) => n > 0 ? Option.some(n) : Option.none(), + * (n) => `Expected positive integer, got ${n}` + * ) + * ) + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const filterMap: { + (f: (a: A) => Option.Option, onNone: (a: A) => string): (self: Argument) => Argument + (self: Argument, f: (a: A) => Option.Option, onNone: (a: A) => string): Argument +} = dual(3, ( + self: Argument, + f: (a: A) => Option.Option, + onNone: (a: A) => string +) => Param.filterMap(self, f, onNone)) + +/** + * Provides a fallback argument to use if this argument fails to parse. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const value = Argument.integer("value").pipe( + * Argument.orElse(() => Argument.string("value")) + * ) + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const orElse: { + (that: LazyArg>): (self: Argument) => Argument + (self: Argument, that: LazyArg>): Argument +} = dual(2, (self: Argument, that: LazyArg>) => Param.orElse(self, that)) + +/** + * Provides a fallback argument, wrapping results in Result to distinguish which succeeded. + * + * @example + * ```ts + * import { Argument } from "effect/unstable/cli" + * + * const source = Argument.file("source").pipe( + * Argument.orElseResult(() => Argument.string("url")) + * ) + * // Returns Result + * ``` + * + * @since 4.0.0 + * @category combinators + */ +export const orElseResult: { + (that: LazyArg>): (self: Argument) => Argument> + (self: Argument, that: LazyArg>): Argument> +} = dual(2, (self: Argument, that: LazyArg>) => Param.orElseResult(self, that)) diff --git a/packages/effect/src/unstable/cli/Flag.ts b/packages/effect/src/unstable/cli/Flag.ts index 45e78ec09..87ce69e74 100644 --- a/packages/effect/src/unstable/cli/Flag.ts +++ b/packages/effect/src/unstable/cli/Flag.ts @@ -250,14 +250,14 @@ export const redacted = (name: string): Flag> => Param * ```ts * import { Flag } from "effect/unstable/cli" * - * const config = Flag.fileString("config-file") + * const config = Flag.fileText("config-file") * // --config-file ./app.json will read the file content * ``` * * @since 4.0.0 * @category constructors */ -export const fileString = (name: string): Flag => Param.fileString(Param.Flag, name) +export const fileText = (name: string): Flag => Param.fileText(Param.Flag, name) /** * Creates a flag that reads and parses the content of the specified file. @@ -343,7 +343,7 @@ export const keyValueMap = (name: string): Flag> => Param * @since 4.0.0 * @category constructors */ -export const none: Flag = Param.none("flag") +export const none: Flag = Param.none(Param.Flag) /** * Adds an alias to a flag, allowing it to be referenced by multiple names. @@ -364,7 +364,7 @@ export const none: Flag = Param.none("flag") * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category aliasing */ export const withAlias: { @@ -388,7 +388,7 @@ export const withAlias: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category help documentation */ export const withDescription: { @@ -415,7 +415,7 @@ export const withDescription: { * // In help: --timeout SECONDS * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category help documentation */ export const withPseudoName: { @@ -447,7 +447,7 @@ export const withPseudoName: { * }) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category optionality */ export const optional = (param: Flag): Flag> => Param.optional(param) @@ -470,7 +470,7 @@ export const optional = (param: Flag): Flag> => Param.opt * // If --host is not provided, defaults to "localhost" * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category optionality */ export const withDefault: { @@ -496,7 +496,7 @@ export const withDefault: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category mapping */ export const map: { @@ -523,7 +523,7 @@ export const map: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category mapping */ export const mapEffect: { @@ -563,7 +563,7 @@ export const mapEffect: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category mapping */ export const mapTryCatch: { @@ -591,7 +591,7 @@ export const mapTryCatch: { * // Result: [true, true, true] * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category repetition */ export const repeated = (flag: Flag): Flag> => Param.repeated(flag) @@ -613,7 +613,7 @@ export const repeated = (flag: Flag): Flag> => Param.repe * // Requires at least 1 tag * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category repetition */ export const atLeast: { @@ -638,7 +638,7 @@ export const atLeast: { * // Allows at most 1 debug flag * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category repetition */ export const atMost: { @@ -663,7 +663,7 @@ export const atMost: { * // Allows 0-5 exclude patterns * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category repetition */ export const between: { @@ -696,7 +696,7 @@ export const between: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category filtering */ export const filterMap: { @@ -732,7 +732,7 @@ export const filterMap: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category filtering */ export const filter: { @@ -764,7 +764,7 @@ export const filter: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category alternatives */ export const orElse: { @@ -800,7 +800,7 @@ export const orElse: { * }) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category alternatives */ export const orElseResult: { @@ -841,7 +841,7 @@ export const orElseResult: { * ) * ``` * - * @since 1.0.0 + * @since 4.0.0 * @category schemas */ export const withSchema: { diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index 616486525..88c028afa 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -1,4 +1,13 @@ /** + * @internal + * + * Param is the polymorphic implementation shared by Argument.ts and Flag.ts. + * The `Kind` type parameter ("argument" | "flag") enables type-safe separation + * while sharing parsing logic and combinators. + * + * Users should import from `Argument` and `Flag` modules, not this module directly. + * This module is not exported from the public API. + * * @since 4.0.0 */ import * as Option from "../../data/Option.ts" @@ -195,7 +204,7 @@ const Proto = { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const maybeParam = Param.string(Param.Flag, "name") * @@ -214,7 +223,7 @@ export const isParam = (u: unknown): u is Param => Predicate.has * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const nameParam = Param.string(Param.Flag, "name") * const optionalParam = Param.optional(nameParam) @@ -269,7 +278,7 @@ export const makeSingle = (params: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Create a string flag * const nameFlag = Param.string(Param.Flag, "name") @@ -298,7 +307,7 @@ export const string = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Create a boolean flag * const verboseFlag = Param.boolean(Param.Flag, "verbose") @@ -328,7 +337,7 @@ export const boolean = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Create an integer flag * const portFlag = Param.integer(Param.Flag, "port") @@ -357,7 +366,7 @@ export const integer = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Create a float flag * const rateFlag = Param.float(Param.Flag, "rate") @@ -386,7 +395,7 @@ export const float = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Create a date flag * const startFlag = Param.date(Param.Flag, "start-date") @@ -417,7 +426,7 @@ export const date = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * type Animal = Dog | Cat * @@ -454,7 +463,7 @@ export const choiceWithValue = < * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const logLevel = Param.choice(Param.Flag, "log-level", [ * "debug", @@ -480,7 +489,7 @@ export const choice = < * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Basic path parameter * const outputPath = Param.path(Param.Flag, "output") @@ -523,7 +532,7 @@ export const path = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Basic directory parameter * const outputDir = Param.directory(Param.Flag, "output-dir") @@ -558,7 +567,7 @@ export const directory = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Basic file parameter * const outputFile = Param.file(Param.Flag, "output") @@ -591,7 +600,7 @@ export const file = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Create a password parameter * const password = Param.redacted(Param.Flag, "password") @@ -620,13 +629,13 @@ export const redacted = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Read a config file as string - * const configContent = Param.fileString(Param.Flag, "config") + * const configContent = Param.fileText(Param.Flag, "config") * * // Read a template file as argument - * const templateContent = Param.fileString(Param.Argument, "template") + * const templateContent = Param.fileText(Param.Argument, "template") * * // Usage: --config config.txt (reads file content into string) * ``` @@ -634,7 +643,7 @@ export const redacted = ( * @since 4.0.0 * @category constructors */ -export const fileString = (kind: Kind, name: string): Param => +export const fileText = (kind: Kind, name: string): Param => makeSingle({ name, primitiveType: Primitive.fileString, @@ -649,7 +658,7 @@ export const fileString = (kind: Kind, name: string): Pa * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Will use the extension of the file passed on the command line to determine * // the parser to use @@ -679,7 +688,7 @@ export const fileParse = ( * @example * ```ts * import { Schema } from "effect/schema" - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Parse JSON config file * const configSchema = Schema.Struct({ @@ -720,7 +729,7 @@ export const fileSchema = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const env = Param.keyValueMap(Param.Flag, "env") * // --env FOO=bar --env BAZ=qux will parse to { FOO: "bar", BAZ: "qux" } @@ -755,7 +764,7 @@ export const keyValueMap = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Create a none parameter for composition * const noneParam = Param.none(Param.Flag) @@ -789,7 +798,7 @@ const FLAG_DASH_REGEX = /^-+/ * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const force = Param.boolean(Param.Flag, "force").pipe( * Param.withAlias("-f"), @@ -825,7 +834,7 @@ export const withAlias: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const verbose = Param.boolean(Param.Flag, "verbose").pipe( * Param.withAlias("-v"), @@ -852,7 +861,7 @@ export const withDescription: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const port = Param.integer(Param.Flag, "port").pipe( * Param.map(n => ({ port: n, url: `http://localhost:${n}` })) @@ -885,7 +894,8 @@ export const map: { * * @example * ```ts - * import { Param, CliError } from "effect/unstable/cli" + * // @internal - this module is not exported publicly + * import { CliError } from "effect/unstable/cli" * import { Effect } from "effect" * * const validatedEmail = Param.string(Param.Flag, "email").pipe( @@ -936,7 +946,7 @@ export const mapEffect: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const parsedJson = Param.string(Param.Flag, "config").pipe( * Param.mapTryCatch( @@ -964,6 +974,7 @@ export const mapTryCatch: { f: (a: A) => B, onError: (error: unknown) => string ) => { + const single = getUnderlyingSingleOrThrow(self) const parse: Parse = (args) => Effect.flatMap(self.parse(args), ([leftover, a]) => Effect.try({ @@ -973,7 +984,7 @@ export const mapTryCatch: { Effect.mapError( (error) => new CliError.InvalidValue({ - option: "unknown", + option: single.name, value: String(a), expected: error }) @@ -1003,7 +1014,7 @@ export const mapTryCatch: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Create an optional port option * // - When not provided: returns Option.none() @@ -1012,7 +1023,7 @@ export const mapTryCatch: { * ``` * * @since 4.0.0 - * @category constructors + * @category combinators */ export const optional = ( param: Param @@ -1041,7 +1052,7 @@ export const optional = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Using the pipe operator to make an option optional * const port = Param.integer(Param.Flag, "port").pipe( @@ -1093,7 +1104,7 @@ export type VariadicParamOptions = { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Basic variadic parameter (0 to infinity) * const tags = Param.variadic(Param.string(Param.Flag, "tag")) @@ -1144,7 +1155,7 @@ export const variadic = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Allow 1-3 file inputs * const files = Param.string(Param.Flag, "file").pipe( @@ -1189,7 +1200,7 @@ export const between: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Allow unlimited file inputs * const files = Param.string(Param.Flag, "file").pipe( @@ -1216,7 +1227,7 @@ export const repeated = ( * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Allow at most 3 warning suppressions * const suppressions = Param.string(Param.Flag, "suppress").pipe( @@ -1248,7 +1259,7 @@ export const atMost: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * // Require at least 2 input files * const inputs = Param.string(Param.Flag, "input").pipe( @@ -1282,7 +1293,7 @@ export const atLeast: { * @example * ```ts * import { Option } from "effect/data" - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const positiveInt = Param.integer(Param.Flag, "count").pipe( * Param.filterMap( @@ -1331,7 +1342,7 @@ export const filterMap: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const evenNumber = Param.integer(Param.Flag, "num").pipe( * Param.filter( @@ -1367,7 +1378,7 @@ export const filter: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const port = Param.integer(Param.Flag, "port").pipe( * Param.withPseudoName("PORT"), @@ -1397,7 +1408,7 @@ export const withPseudoName: { * @example * ```ts * import { Schema } from "effect/schema" - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const isEmail = Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) * @@ -1437,7 +1448,7 @@ export const withSchema: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const config = Param.file(Param.Flag, "config").pipe( * Param.orElse(() => Param.string(Param.Flag, "config-url")) @@ -1470,7 +1481,7 @@ export const orElse: { * * @example * ```ts - * import { Param } from "effect/unstable/cli" + * // @internal - this module is not exported publicly * * const configSource = Param.file(Param.Flag, "config").pipe( * Param.orElseResult(() => Param.string(Param.Flag, "config-url")) diff --git a/packages/effect/src/unstable/cli/index.ts b/packages/effect/src/unstable/cli/index.ts index 5624c9cde..f66324824 100644 --- a/packages/effect/src/unstable/cli/index.ts +++ b/packages/effect/src/unstable/cli/index.ts @@ -32,11 +32,6 @@ export * as HelpDoc from "./HelpDoc.ts" */ export * as CliOutput from "./CliOutput.ts" -/** - * @since 4.0.0 - */ -export * as Param from "./Param.ts" - /** * @since 4.0.0 */ From 99e72362ccd68b95c10ec1d3f9d8767223438dd4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Nov 2025 12:29:57 -0500 Subject: [PATCH 22/29] refactor(cli): add Command.Any, remove as any casts in handler/command - Add Command.Any type alias for type-safe command navigation - Extract findMatchingFlag helper to eliminate 3x duplication in handler.ts - Remove all `as any` casts from handler.ts and internal/command.ts --- packages/effect/src/unstable/cli/Argument.ts | 71 ++++--- packages/effect/src/unstable/cli/Command.ts | 11 +- packages/effect/src/unstable/cli/Flag.ts | 91 ++++++--- packages/effect/src/unstable/cli/Param.ts | 183 +++++++++++------- packages/effect/src/unstable/cli/Primitive.ts | 46 +++-- .../src/unstable/cli/internal/command.ts | 2 +- .../internal/completions/dynamic/handler.ts | 40 ++-- .../src/unstable/cli/internal/parser.ts | 2 +- .../test/unstable/cli/Arguments.test.ts | 152 ++++++++++++++- .../effect/test/unstable/cli/Errors.test.ts | 2 +- 10 files changed, 437 insertions(+), 163 deletions(-) diff --git a/packages/effect/src/unstable/cli/Argument.ts b/packages/effect/src/unstable/cli/Argument.ts index 1a6686b05..c4f90c2e5 100644 --- a/packages/effect/src/unstable/cli/Argument.ts +++ b/packages/effect/src/unstable/cli/Argument.ts @@ -14,6 +14,10 @@ import type { Environment } from "./Command.ts" import * as Param from "./Param.ts" import type * as Primitive from "./Primitive.ts" +// ------------------------------------------------------------------------------------- +// models +// ------------------------------------------------------------------------------------- + /** * Represents a positional command-line argument. * @@ -25,7 +29,11 @@ import type * as Primitive from "./Primitive.ts" * @since 4.0.0 * @category models */ -export interface Argument extends Param.Param {} +export interface Argument extends Param.Param {} + +// ------------------------------------------------------------------------------------- +// constructors +// ------------------------------------------------------------------------------------- /** * Creates a positional string argument. @@ -40,7 +48,7 @@ export interface Argument extends Param.Param {} * @since 4.0.0 * @category constructors */ -export const string = (name: string): Argument => Param.string(Param.Argument, name) +export const string = (name: string): Argument => Param.string(Param.argumentKind, name) /** * Creates a positional integer argument. @@ -55,7 +63,7 @@ export const string = (name: string): Argument => Param.string(Param.Arg * @since 4.0.0 * @category constructors */ -export const integer = (name: string): Argument => Param.integer(Param.Argument, name) +export const integer = (name: string): Argument => Param.integer(Param.argumentKind, name) /** * Creates a positional file path argument. @@ -73,7 +81,7 @@ export const integer = (name: string): Argument => Param.integer(Param.A */ export const file = (name: string, options?: { readonly mustExist?: boolean | undefined -}): Argument => Param.file(Param.Argument, name, options) +}): Argument => Param.file(Param.argumentKind, name, options) /** * Creates a positional directory path argument. @@ -90,7 +98,7 @@ export const file = (name: string, options?: { */ export const directory = (name: string, options?: { readonly mustExist?: boolean | undefined -}): Argument => Param.directory(Param.Argument, name, options) +}): Argument => Param.directory(Param.argumentKind, name, options) /** * Creates a positional float argument. @@ -105,7 +113,7 @@ export const directory = (name: string, options?: { * @since 4.0.0 * @category constructors */ -export const float = (name: string): Argument => Param.float(Param.Argument, name) +export const float = (name: string): Argument => Param.float(Param.argumentKind, name) /** * Creates a positional date argument. @@ -120,7 +128,7 @@ export const float = (name: string): Argument => Param.float(Param.Argum * @since 4.0.0 * @category constructors */ -export const date = (name: string): Argument => Param.date(Param.Argument, name) +export const date = (name: string): Argument => Param.date(Param.argumentKind, name) /** * Creates a positional choice argument. @@ -138,7 +146,7 @@ export const date = (name: string): Argument => Param.date(Param.Argument, export const choice = >( name: string, choices: Choices -): Argument => Param.choice(Param.Argument, name, choices) +): Argument => Param.choice(Param.argumentKind, name, choices) /** * Creates a positional path argument. @@ -156,7 +164,7 @@ export const choice = >( export const path = (name: string, options?: { pathType?: "file" | "directory" | "either" mustExist?: boolean -}): Argument => Param.path(Param.Argument, name, options) +}): Argument => Param.path(Param.argumentKind, name, options) /** * Creates a positional redacted argument that obscures its value. @@ -171,7 +179,7 @@ export const path = (name: string, options?: { * @since 4.0.0 * @category constructors */ -export const redacted = (name: string): Argument> => Param.redacted(Param.Argument, name) +export const redacted = (name: string): Argument> => Param.redacted(Param.argumentKind, name) /** * Creates a positional argument that reads file content as a string. @@ -186,7 +194,7 @@ export const redacted = (name: string): Argument> => P * @since 4.0.0 * @category constructors */ -export const fileText = (name: string): Argument => Param.fileText(Param.Argument, name) +export const fileText = (name: string): Argument => Param.fileText(Param.argumentKind, name) /** * Creates a positional argument that reads and validates file content using a schema. @@ -204,7 +212,7 @@ export const fileText = (name: string): Argument => Param.fileText(Param export const fileParse = ( name: string, options?: Primitive.FileParseOptions | undefined -): Argument => Param.fileParse(Param.Argument, name, options) +): Argument => Param.fileParse(Param.argumentKind, name, options) /** * Creates a positional argument that reads and validates file content using a schema. @@ -231,7 +239,7 @@ export const fileSchema = ( name: string, schema: Schema.Codec, options?: Primitive.FileSchemaOptions | undefined -): Argument => Param.fileSchema(Param.Argument, name, schema, options) +): Argument => Param.fileSchema(Param.argumentKind, name, schema, options) /** * Creates an empty sentinel argument that always fails to parse. @@ -247,7 +255,11 @@ export const fileSchema = ( * @since 4.0.0 * @category constructors */ -export const none: Argument = Param.none(Param.Argument) +export const none: Argument = Param.none(Param.argumentKind) + +// ------------------------------------------------------------------------------------- +// combinators +// ------------------------------------------------------------------------------------- /** * Makes a positional argument optional. @@ -427,6 +439,7 @@ export const mapTryCatch: { * * @since 4.0.0 * @category combinators + * @deprecated Use `variadic` instead. `repeated` is equivalent to `variadic` with no options. */ export const repeated = (arg: Argument): Argument> => Param.repeated(arg) @@ -526,27 +539,41 @@ export const withSchema: { export const choiceWithValue = >( name: string, choices: Choices -): Argument => Param.choiceWithValue(Param.Argument, name, choices) +): Argument => Param.choiceWithValue(Param.argumentKind, name, choices) + +// ------------------------------------------------------------------------------------- +// metadata +// ------------------------------------------------------------------------------------- /** - * Sets a custom display name for the argument type in help documentation. + * Sets a custom metavar (placeholder name) for the argument in help documentation. + * + * The metavar is displayed in usage text to indicate what value the user should provide. + * For example, `` shows `FILE` as the metavar. * * @example * ```ts * import { Argument } from "effect/unstable/cli" * * const port = Argument.integer("port").pipe( - * Argument.withPseudoName("PORT") + * Argument.withMetavar("PORT") * ) * ``` * * @since 4.0.0 - * @category combinators + * @category metadata + */ +export const withMetavar: { + (metavar: string): (self: Argument) => Argument + (self: Argument, metavar: string): Argument +} = dual(2, (self: Argument, metavar: string) => Param.withMetavar(self, metavar)) + +/** + * @deprecated Use `withMetavar` instead. + * @since 4.0.0 + * @category metadata */ -export const withPseudoName: { - (pseudoName: string): (self: Argument) => Argument - (self: Argument, pseudoName: string): Argument -} = dual(2, (self: Argument, pseudoName: string) => Param.withPseudoName(self, pseudoName)) +export const withPseudoName = withMetavar /** * Filters parsed values, failing with a custom error message if the predicate returns false. diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 3f4d9b820..30ca7530f 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -27,9 +27,6 @@ import * as Parser from "./internal/parser.ts" import type * as Param from "./Param.ts" import * as Prompt from "./Prompt.ts" -// Re-export toImpl for internal modules -export { toImpl } from "./internal/command.ts" - /* ========================================================================== */ /* Public Types */ /* ========================================================================== */ @@ -196,6 +193,14 @@ export declare namespace Command { : A extends Config ? Infer : never } + + /** + * Represents any Command regardless of its type parameters. + * + * @since 4.0.0 + * @category models + */ + export type Any = Command } /** diff --git a/packages/effect/src/unstable/cli/Flag.ts b/packages/effect/src/unstable/cli/Flag.ts index 87ce69e74..76007a8cf 100644 --- a/packages/effect/src/unstable/cli/Flag.ts +++ b/packages/effect/src/unstable/cli/Flag.ts @@ -13,13 +13,21 @@ import type * as CliError from "./CliError.ts" import * as Param from "./Param.ts" import type * as Primitive from "./Primitive.ts" +// ------------------------------------------------------------------------------------- +// models +// ------------------------------------------------------------------------------------- + /** * Represents a command-line flag. * * @since 4.0.0 * @category models */ -export interface Flag extends Param.Param {} +export interface Flag extends Param.Param {} + +// ------------------------------------------------------------------------------------- +// constructors +// ------------------------------------------------------------------------------------- /** * Creates a string flag that accepts text input. @@ -35,7 +43,7 @@ export interface Flag extends Param.Param {} * @since 4.0.0 * @category constructors */ -export const string = (name: string): Flag => Param.string(Param.Flag, name) +export const string = (name: string): Flag => Param.string(Param.flagKind, name) /** * Creates a boolean flag that can be enabled or disabled. @@ -51,7 +59,7 @@ export const string = (name: string): Flag => Param.string(Param.Flag, n * @since 4.0.0 * @category constructors */ -export const boolean = (name: string): Flag => Param.boolean(Param.Flag, name) +export const boolean = (name: string): Flag => Param.boolean(Param.flagKind, name) /** * Creates an integer flag that accepts whole number input. @@ -67,7 +75,7 @@ export const boolean = (name: string): Flag => Param.boolean(Param.Flag * @since 4.0.0 * @category constructors */ -export const integer = (name: string): Flag => Param.integer(Param.Flag, name) +export const integer = (name: string): Flag => Param.integer(Param.flagKind, name) /** * Creates a float flag that accepts decimal number input. @@ -83,7 +91,7 @@ export const integer = (name: string): Flag => Param.integer(Param.Flag, * @since 4.0.0 * @category constructors */ -export const float = (name: string): Flag => Param.float(Param.Flag, name) +export const float = (name: string): Flag => Param.float(Param.flagKind, name) /** * Creates a date flag that accepts date input in ISO format. @@ -99,7 +107,7 @@ export const float = (name: string): Flag => Param.float(Param.Flag, nam * @since 4.0.0 * @category constructors */ -export const date = (name: string): Flag => Param.date(Param.Flag, name) +export const date = (name: string): Flag => Param.date(Param.flagKind, name) /** * Constructs option parameters that represent a choice between several inputs. @@ -126,7 +134,7 @@ export const date = (name: string): Flag => Param.date(Param.Flag, name) export const choiceWithValue = >( name: string, choices: Choice -): Flag => Param.choiceWithValue(Param.Flag, name, choices) +): Flag => Param.choiceWithValue(Param.flagKind, name, choices) /** * Simpler variant of `choiceWithValue` which maps each string to itself. @@ -137,7 +145,7 @@ export const choiceWithValue = >( name: string, choices: Choices -): Flag => Param.choice(Param.Flag, name, choices) +): Flag => Param.choice(Param.flagKind, name, choices) /** * Creates a path flag that accepts file system path input with validation options. @@ -169,7 +177,7 @@ export const path = (name: string, options?: { readonly pathType?: "file" | "directory" | "either" | undefined readonly mustExist?: boolean | undefined readonly typeName?: string | undefined -}): Flag => Param.path(Param.Flag, name, options) +}): Flag => Param.path(Param.flagKind, name, options) /** * Creates a file path flag that accepts file paths with optional existence validation. @@ -192,7 +200,7 @@ export const path = (name: string, options?: { */ export const file = (name: string, options?: { readonly mustExist?: boolean | undefined -}): Flag => Param.file(Param.Flag, name, options) +}): Flag => Param.file(Param.flagKind, name, options) /** * Creates a directory path flag that accepts directory paths with optional existence validation. @@ -215,7 +223,7 @@ export const file = (name: string, options?: { */ export const directory = (name: string, options?: { readonly mustExist?: boolean | undefined -}): Flag => Param.directory(Param.Flag, name, options) +}): Flag => Param.directory(Param.flagKind, name, options) /** * Creates a redacted flag that securely handles sensitive string input. @@ -241,7 +249,7 @@ export const directory = (name: string, options?: { * @since 4.0.0 * @category constructors */ -export const redacted = (name: string): Flag> => Param.redacted(Param.Flag, name) +export const redacted = (name: string): Flag> => Param.redacted(Param.flagKind, name) /** * Creates a flag that reads and returns file content as a string. @@ -257,7 +265,7 @@ export const redacted = (name: string): Flag> => Param * @since 4.0.0 * @category constructors */ -export const fileText = (name: string): Flag => Param.fileText(Param.Flag, name) +export const fileText = (name: string): Flag => Param.fileText(Param.flagKind, name) /** * Creates a flag that reads and parses the content of the specified file. @@ -283,7 +291,7 @@ export const fileText = (name: string): Flag => Param.fileText(Param.Fla export const fileParse = ( name: string, options?: Primitive.FileParseOptions | undefined -): Flag => Param.fileParse(Param.Flag, name, options) +): Flag => Param.fileParse(Param.flagKind, name, options) /** * Creates a flag that reads and validates file content using the specified @@ -309,24 +317,34 @@ export const fileSchema = ( name: string, schema: Schema.Codec, options?: Primitive.FileSchemaOptions | undefined -): Flag => Param.fileSchema(Param.Flag, name, schema, options) +): Flag => Param.fileSchema(Param.flagKind, name, schema, options) /** * Creates a flag that parses key=value pairs. * Useful for options that accept configuration values. * + * Note: Requires at least one key=value pair. Multiple pairs are merged + * into a single record. + * * @example * ```ts * import { Flag } from "effect/unstable/cli" * - * const env = Flag.keyValueMap("env") - * // --env FOO=bar will parse to { FOO: "bar" } + * const env = Flag.keyValuePair("env") + * // --env FOO=bar --env BAZ=qux will parse to { FOO: "bar", BAZ: "qux" } * ``` * * @since 4.0.0 * @category constructors */ -export const keyValueMap = (name: string): Flag> => Param.keyValueMap(Param.Flag, name) +export const keyValuePair = (name: string): Flag> => Param.keyValuePair(Param.flagKind, name) + +/** + * @deprecated Use `keyValuePair` instead. + * @since 4.0.0 + * @category constructors + */ +export const keyValueMap = keyValuePair /** * Creates an empty sentinel flag that always fails to parse. @@ -343,7 +361,11 @@ export const keyValueMap = (name: string): Flag> => Param * @since 4.0.0 * @category constructors */ -export const none: Flag = Param.none(Param.Flag) +export const none: Flag = Param.none(Param.flagKind) + +// ------------------------------------------------------------------------------------- +// combinators +// ------------------------------------------------------------------------------------- /** * Adds an alias to a flag, allowing it to be referenced by multiple names. @@ -396,32 +418,46 @@ export const withDescription: { (self: Flag, description: string): Flag } = dual(2, (self: Flag, description: string) => Param.withDescription(self, description)) +// ------------------------------------------------------------------------------------- +// metadata +// ------------------------------------------------------------------------------------- + /** - * Adds a pseudo name to a flag for help documentation display. + * Sets a custom metavar (placeholder name) for the flag in help documentation. + * + * The metavar is displayed in usage text to indicate what value the user should provide. + * For example, `--output FILE` shows `FILE` as the metavar. * * @example * ```ts * import { Flag } from "effect/unstable/cli" * * const databaseFlag = Flag.string("database-url").pipe( - * Flag.withPseudoName("URL"), + * Flag.withMetavar("URL"), * Flag.withDescription("Database connection URL") * ) * // In help: --database-url URL * * const timeoutFlag = Flag.integer("timeout").pipe( - * Flag.withPseudoName("SECONDS") + * Flag.withMetavar("SECONDS") * ) * // In help: --timeout SECONDS * ``` * * @since 4.0.0 - * @category help documentation + * @category metadata + */ +export const withMetavar: { + (metavar: string): (self: Flag) => Flag + (self: Flag, metavar: string): Flag +} = dual(2, (self: Flag, metavar: string) => Param.withMetavar(self, metavar)) + +/** + * @deprecated Use `withMetavar` instead. + * @since 4.0.0 + * @category metadata */ -export const withPseudoName: { - (pseudoName: string): (self: Flag) => Flag - (self: Flag, pseudoName: string): Flag -} = dual(2, (self: Flag, pseudoName: string) => Param.withPseudoName(self, pseudoName)) +export const withPseudoName = withMetavar /** * Makes a flag optional, returning an Option type that can be None if not provided. @@ -591,6 +627,7 @@ export const mapTryCatch: { * // Result: [true, true, true] * ``` * + * @deprecated Use `Flag.atLeast(flag, 0)` or `Flag.between(flag, min, max)` instead. * @since 4.0.0 * @category repetition */ diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index 88c028afa..713d947fa 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -44,16 +44,34 @@ export interface Param extends Param.Variance export type ParamKind = "argument" | "flag" /** + * Kind discriminator for positional argument parameters. + * + * @since 4.0.0 + * @category constants + */ +export const argumentKind: "argument" = "argument" as const + +/** + * Kind discriminator for flag parameters. + * + * @since 4.0.0 + * @category constants + */ +export const flagKind: "flag" = "flag" as const + +/** + * @deprecated Use `argumentKind` instead. * @since 4.0.0 * @category constants */ -export const Argument: "argument" = "argument" as const +export const Argument: "argument" = argumentKind /** + * @deprecated Use `flagKind` instead. * @since 4.0.0 * @category constants */ -export const Flag: "flag" = "flag" as const +export const Flag: "flag" = flagKind /** * Represents any parameter. @@ -206,7 +224,7 @@ const Proto = { * ```ts * // @internal - this module is not exported publicly * - * const maybeParam = Param.string(Param.Flag, "name") + * const maybeParam = Param.string(Param.flagKind, "name") * * if (Param.isParam(maybeParam)) { * console.log("This is a Param") @@ -225,7 +243,7 @@ export const isParam = (u: unknown): u is Param => Predicate.has * ```ts * // @internal - this module is not exported publicly * - * const nameParam = Param.string(Param.Flag, "name") + * const nameParam = Param.string(Param.flagKind, "name") * const optionalParam = Param.optional(nameParam) * * console.log(Param.isSingle(nameParam)) // true @@ -281,10 +299,10 @@ export const makeSingle = (params: { * // @internal - this module is not exported publicly * * // Create a string flag - * const nameFlag = Param.string(Param.Flag, "name") + * const nameFlag = Param.string(Param.flagKind, "name") * * // Create a string argument - * const fileArg = Param.string(Param.Argument, "file") + * const fileArg = Param.string(Param.argumentKind, "file") * * // Usage in CLI: --name "John Doe" or as positional argument * ``` @@ -310,10 +328,10 @@ export const string = ( * // @internal - this module is not exported publicly * * // Create a boolean flag - * const verboseFlag = Param.boolean(Param.Flag, "verbose") + * const verboseFlag = Param.boolean(Param.flagKind, "verbose") * * // Create a boolean argument - * const enableArg = Param.boolean(Param.Argument, "enable") + * const enableArg = Param.boolean(Param.argumentKind, "enable") * * // Usage in CLI: --verbose (defaults to true when present, false when absent) * // or as positional: true/false @@ -340,10 +358,10 @@ export const boolean = ( * // @internal - this module is not exported publicly * * // Create an integer flag - * const portFlag = Param.integer(Param.Flag, "port") + * const portFlag = Param.integer(Param.flagKind, "port") * * // Create an integer argument - * const countArg = Param.integer(Param.Argument, "count") + * const countArg = Param.integer(Param.argumentKind, "count") * * // Usage in CLI: --port 8080 or as positional argument: 42 * ``` @@ -369,10 +387,10 @@ export const integer = ( * // @internal - this module is not exported publicly * * // Create a float flag - * const rateFlag = Param.float(Param.Flag, "rate") + * const rateFlag = Param.float(Param.flagKind, "rate") * * // Create a float argument - * const thresholdArg = Param.float(Param.Argument, "threshold") + * const thresholdArg = Param.float(Param.argumentKind, "threshold") * * // Usage in CLI: --rate 0.95 or as positional argument: 3.14159 * ``` @@ -398,10 +416,10 @@ export const float = ( * // @internal - this module is not exported publicly * * // Create a date flag - * const startFlag = Param.date(Param.Flag, "start-date") + * const startFlag = Param.date(Param.flagKind, "start-date") * * // Create a date argument - * const dueDateArg = Param.date(Param.Argument, "due-date") + * const dueDateArg = Param.date(Param.argumentKind, "due-date") * * // Usage in CLI: --start-date "2023-12-25" or as positional: "2023-01-01" * // Parses to JavaScript Date object @@ -438,7 +456,7 @@ export const date = ( * readonly _tag: "Cat" * } * - * const animal = Param.choiceWithValue(Param.Flag, "animal", [ + * const animal = Param.choiceWithValue(Param.flagKind, "animal", [ * ["dog", { _tag: "Dog" }], * ["cat", { _tag: "Cat" }] * ]) @@ -465,7 +483,7 @@ export const choiceWithValue = < * ```ts * // @internal - this module is not exported publicly * - * const logLevel = Param.choice(Param.Flag, "log-level", [ + * const logLevel = Param.choice(Param.flagKind, "log-level", [ * "debug", * "info", * "warn", @@ -492,13 +510,13 @@ export const choice = < * // @internal - this module is not exported publicly * * // Basic path parameter - * const outputPath = Param.path(Param.Flag, "output") + * const outputPath = Param.path(Param.flagKind, "output") * * // Path that must exist - * const inputPath = Param.path(Param.Flag, "input", { mustExist: true }) + * const inputPath = Param.path(Param.flagKind, "input", { mustExist: true }) * * // File-only path - * const configFile = Param.path(Param.Flag, "config", { + * const configFile = Param.path(Param.flagKind, "config", { * pathType: "file", * mustExist: true, * typeName: "config-file" @@ -535,10 +553,10 @@ export const path = ( * // @internal - this module is not exported publicly * * // Basic directory parameter - * const outputDir = Param.directory(Param.Flag, "output-dir") + * const outputDir = Param.directory(Param.flagKind, "output-dir") * * // Directory that must exist - * const sourceDir = Param.directory(Param.Flag, "source", { mustExist: true }) + * const sourceDir = Param.directory(Param.flagKind, "source", { mustExist: true }) * * // Usage: --output-dir /path/to/dir --source /existing/dir * ``` @@ -570,10 +588,10 @@ export const directory = ( * // @internal - this module is not exported publicly * * // Basic file parameter - * const outputFile = Param.file(Param.Flag, "output") + * const outputFile = Param.file(Param.flagKind, "output") * * // File that must exist - * const inputFile = Param.file(Param.Flag, "input", { mustExist: true }) + * const inputFile = Param.file(Param.flagKind, "input", { mustExist: true }) * * // Usage: --output result.txt --input existing-file.txt * ``` @@ -603,10 +621,10 @@ export const file = ( * // @internal - this module is not exported publicly * * // Create a password parameter - * const password = Param.redacted(Param.Flag, "password") + * const password = Param.redacted(Param.flagKind, "password") * * // Create an API key argument - * const apiKey = Param.redacted(Param.Argument, "api-key") + * const apiKey = Param.redacted(Param.argumentKind, "api-key") * * // Usage: --password (value will be hidden in help/logs) * ``` @@ -632,10 +650,10 @@ export const redacted = ( * // @internal - this module is not exported publicly * * // Read a config file as string - * const configContent = Param.fileText(Param.Flag, "config") + * const configContent = Param.fileText(Param.flagKind, "config") * * // Read a template file as argument - * const templateContent = Param.fileText(Param.Argument, "template") + * const templateContent = Param.fileText(Param.argumentKind, "template") * * // Usage: --config config.txt (reads file content into string) * ``` @@ -646,7 +664,7 @@ export const redacted = ( export const fileText = (kind: Kind, name: string): Param => makeSingle({ name, - primitiveType: Primitive.fileString, + primitiveType: Primitive.fileText, kind }) @@ -662,10 +680,10 @@ export const fileText = (kind: Kind, name: string): Para * * // Will use the extension of the file passed on the command line to determine * // the parser to use - * const config = Param.fileParse(Param.Flag, "config") + * const config = Param.fileParse(Param.flagKind, "config") * * // Will use the JSON parser - * const jsonConfig = Param.fileParse(Param.Flag, "json-config", { format: "json" }) + * const jsonConfig = Param.fileParse(Param.flagKind, "json-config", { format: "json" }) * ``` * * @since 4.0.0 @@ -696,12 +714,12 @@ export const fileParse = ( * host: Schema.String * }).pipe(Schema.fromJsonString) * - * const config = Param.fileSchema(Param.Flag, "config", configSchema, { + * const config = Param.fileSchema(Param.flagKind, "config", configSchema, { * format: "json" * }) * * // Parse YAML file - * const yamlConfig = Param.fileSchema(Param.Flag, "config", configSchema, { + * const yamlConfig = Param.fileSchema(Param.flagKind, "config", configSchema, { * format: "yaml" * }) * @@ -727,21 +745,24 @@ export const fileSchema = ( * Creates a param that parses key=value pairs. * Useful for options that accept configuration values. * + * Note: Requires at least one key=value pair. The parsed pairs are merged + * into a single record object. + * * @example * ```ts * // @internal - this module is not exported publicly * - * const env = Param.keyValueMap(Param.Flag, "env") + * const env = Param.keyValuePair(Param.flagKind, "env") * // --env FOO=bar --env BAZ=qux will parse to { FOO: "bar", BAZ: "qux" } * - * const props = Param.keyValueMap(Param.Flag, "property") + * const props = Param.keyValuePair(Param.flagKind, "property") * // --property name=value --property debug=true * ``` * * @since 4.0.0 * @category constructors */ -export const keyValueMap = ( +export const keyValuePair = ( kind: Kind, name: string ): Param> => @@ -749,7 +770,7 @@ export const keyValueMap = ( variadic( makeSingle({ name, - primitiveType: Primitive.keyValueMap, + primitiveType: Primitive.keyValuePair, kind }), { min: 1 } @@ -757,6 +778,13 @@ export const keyValueMap = ( (objects) => Object.assign({}, ...objects) ) +/** + * @deprecated Use `keyValuePair` instead. + * @since 4.0.0 + * @category constructors + */ +export const keyValueMap = keyValuePair + /** * Creates an empty sentinel parameter that always fails to parse. * @@ -767,12 +795,12 @@ export const keyValueMap = ( * // @internal - this module is not exported publicly * * // Create a none parameter for composition - * const noneParam = Param.none(Param.Flag) + * const noneParam = Param.none(Param.flagKind) * * // Often used in conditional parameter creation * const conditionalParam = process.env.NODE_ENV === "production" - * ? Param.string(Param.Flag, "my-dev-flag") - * : Param.none(Param.Flag) + * ? Param.string(Param.flagKind, "my-dev-flag") + * : Param.none(Param.flagKind) * ``` * * @since 4.0.0 @@ -800,13 +828,13 @@ const FLAG_DASH_REGEX = /^-+/ * ```ts * // @internal - this module is not exported publicly * - * const force = Param.boolean(Param.Flag, "force").pipe( + * const force = Param.boolean(Param.flagKind, "force").pipe( * Param.withAlias("-f"), * Param.withAlias("--no-prompt") * ) * * // Also works on composed params: - * const count = Param.integer(Param.Flag, "count").pipe( + * const count = Param.integer(Param.flagKind, "count").pipe( * Param.optional, * Param.withAlias("-c") // finds the underlying Single and adds alias * ) @@ -836,7 +864,7 @@ export const withAlias: { * ```ts * // @internal - this module is not exported publicly * - * const verbose = Param.boolean(Param.Flag, "verbose").pipe( + * const verbose = Param.boolean(Param.flagKind, "verbose").pipe( * Param.withAlias("-v"), * Param.withDescription("Enable verbose output") * ) @@ -863,7 +891,7 @@ export const withDescription: { * ```ts * // @internal - this module is not exported publicly * - * const port = Param.integer(Param.Flag, "port").pipe( + * const port = Param.integer(Param.flagKind, "port").pipe( * Param.map(n => ({ port: n, url: `http://localhost:${n}` })) * ) * ``` @@ -898,7 +926,7 @@ export const map: { * import { CliError } from "effect/unstable/cli" * import { Effect } from "effect" * - * const validatedEmail = Param.string(Param.Flag, "email").pipe( + * const validatedEmail = Param.string(Param.flagKind, "email").pipe( * Param.mapEffect(email => * email.includes("@") * ? Effect.succeed(email) @@ -948,7 +976,7 @@ export const mapEffect: { * ```ts * // @internal - this module is not exported publicly * - * const parsedJson = Param.string(Param.Flag, "config").pipe( + * const parsedJson = Param.string(Param.flagKind, "config").pipe( * Param.mapTryCatch( * str => JSON.parse(str), * error => `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` @@ -1019,7 +1047,7 @@ export const mapTryCatch: { * // Create an optional port option * // - When not provided: returns Option.none() * // - When provided: returns Option.some(parsedValue) - * const port = Param.optional(Param.integer(Param.Flag, "port")) + * const port = Param.optional(Param.integer(Param.flagKind, "port")) * ``` * * @since 4.0.0 @@ -1055,12 +1083,12 @@ export const optional = ( * // @internal - this module is not exported publicly * * // Using the pipe operator to make an option optional - * const port = Param.integer(Param.Flag, "port").pipe( + * const port = Param.integer(Param.flagKind, "port").pipe( * Param.withDefault(8080) * ) * * // Can also be used with other combinators - * const verbose = Param.boolean(Param.Flag, "verbose").pipe( + * const verbose = Param.boolean(Param.flagKind, "verbose").pipe( * Param.withAlias("-v"), * Param.withDescription("Enable verbose output"), * Param.withDefault(false) @@ -1107,16 +1135,16 @@ export type VariadicParamOptions = { * // @internal - this module is not exported publicly * * // Basic variadic parameter (0 to infinity) - * const tags = Param.variadic(Param.string(Param.Flag, "tag")) + * const tags = Param.variadic(Param.string(Param.flagKind, "tag")) * * // Variadic with minimum count * const inputs = Param.variadic( - * Param.string(Param.Flag, "input"), + * Param.string(Param.flagKind, "input"), * { min: 1 } // at least 1 required * ) * * // Variadic with both min and max - * const limited = Param.variadic(Param.string(Param.Flag, "item"), { + * const limited = Param.variadic(Param.string(Param.flagKind, "item"), { * min: 2, // at least 2 times * max: 2, // at most 5 times * }) @@ -1158,7 +1186,7 @@ export const variadic = ( * // @internal - this module is not exported publicly * * // Allow 1-3 file inputs - * const files = Param.string(Param.Flag, "file").pipe( + * const files = Param.string(Param.flagKind, "file").pipe( * Param.between(1, 3), * Param.withAlias("-f") * ) @@ -1167,7 +1195,7 @@ export const variadic = ( * // Result: ["a.txt", "b.txt"] * * // Allow 0 or more tags - * const tags = Param.string(Param.Flag, "tag").pipe( + * const tags = Param.string(Param.flagKind, "tag").pipe( * Param.between(0, Number.MAX_SAFE_INTEGER) * ) * @@ -1203,7 +1231,7 @@ export const between: { * // @internal - this module is not exported publicly * * // Allow unlimited file inputs - * const files = Param.string(Param.Flag, "file").pipe( + * const files = Param.string(Param.flagKind, "file").pipe( * Param.repeated, * Param.withAlias("-f") * ) @@ -1214,6 +1242,7 @@ export const between: { * * @since 4.0.0 * @category combinators + * @deprecated Use `variadic` instead. `repeated` is equivalent to `variadic` with no options. */ export const repeated = ( self: Param @@ -1230,7 +1259,7 @@ export const repeated = ( * // @internal - this module is not exported publicly * * // Allow at most 3 warning suppressions - * const suppressions = Param.string(Param.Flag, "suppress").pipe( + * const suppressions = Param.string(Param.flagKind, "suppress").pipe( * Param.atMost(3) * ) * @@ -1262,7 +1291,7 @@ export const atMost: { * // @internal - this module is not exported publicly * * // Require at least 2 input files - * const inputs = Param.string(Param.Flag, "input").pipe( + * const inputs = Param.string(Param.flagKind, "input").pipe( * Param.atLeast(2), * Param.withAlias("-i") * ) @@ -1295,7 +1324,7 @@ export const atLeast: { * import { Option } from "effect/data" * // @internal - this module is not exported publicly * - * const positiveInt = Param.integer(Param.Flag, "count").pipe( + * const positiveInt = Param.integer(Param.flagKind, "count").pipe( * Param.filterMap( * (n) => n > 0 ? Option.some(n) : Option.none(), * (n) => `Expected positive integer, got ${n}` @@ -1344,7 +1373,7 @@ export const filterMap: { * ```ts * // @internal - this module is not exported publicly * - * const evenNumber = Param.integer(Param.Flag, "num").pipe( + * const evenNumber = Param.integer(Param.flagKind, "num").pipe( * Param.filter( * n => n % 2 === 0, * n => `Expected even number, got ${n}` @@ -1372,36 +1401,44 @@ export const filter: { ) => filterMap(self, Option.liftPredicate(predicate), onFalse)) /** - * Sets a custom display name for the param type in help documentation. + * Sets a custom metavar (placeholder name) for the param in help documentation. * - * This is useful when you want to override the default type name shown in help text. + * The metavar is displayed in usage text to indicate what value the user should provide. + * For example, `--output FILE` shows `FILE` as the metavar. * * @example * ```ts * // @internal - this module is not exported publicly * - * const port = Param.integer(Param.Flag, "port").pipe( - * Param.withPseudoName("PORT"), + * const port = Param.integer(Param.flagKind, "port").pipe( + * Param.withMetavar("PORT"), * Param.filter(p => p >= 1 && p <= 65535, () => "Port must be between 1 and 65535") * ) * ``` * * @since 4.0.0 - * @category combinators + * @category metadata */ -export const withPseudoName: { - (pseudoName: string): (self: Param) => Param - (self: Param, pseudoName: string): Param +export const withMetavar: { + (metavar: string): (self: Param) => Param + (self: Param, metavar: string): Param } = dual(2, ( self: Param, - pseudoName: string + metavar: string ) => transformSingle(self, (single) => makeSingle({ ...single, - typeName: pseudoName + typeName: metavar }))) +/** + * @deprecated Use `withMetavar` instead. + * @since 4.0.0 + * @category metadata + */ +export const withPseudoName = withMetavar + /** * Validates parsed values against a Schema, providing detailed error messages. * @@ -1416,7 +1453,7 @@ export const withPseudoName: { * Schema.check(isEmail) * ) * - * const email = Param.string(Param.Flag, "email").pipe( + * const email = Param.string(Param.flagKind, "email").pipe( * Param.withSchema(Email) * ) * ``` @@ -1450,8 +1487,8 @@ export const withSchema: { * ```ts * // @internal - this module is not exported publicly * - * const config = Param.file(Param.Flag, "config").pipe( - * Param.orElse(() => Param.string(Param.Flag, "config-url")) + * const config = Param.file(Param.flagKind, "config").pipe( + * Param.orElse(() => Param.string(Param.flagKind, "config-url")) * ) * ``` * @@ -1483,8 +1520,8 @@ export const orElse: { * ```ts * // @internal - this module is not exported publicly * - * const configSource = Param.file(Param.Flag, "config").pipe( - * Param.orElseResult(() => Param.string(Param.Flag, "config-url")) + * const configSource = Param.file(Param.flagKind, "config").pipe( + * Param.orElseResult(() => Param.string(Param.flagKind, "config-url")) * ) * // Returns Result * ``` diff --git a/packages/effect/src/unstable/cli/Primitive.ts b/packages/effect/src/unstable/cli/Primitive.ts index bd441e6cc..dc723ee81 100644 --- a/packages/effect/src/unstable/cli/Primitive.ts +++ b/packages/effect/src/unstable/cli/Primitive.ts @@ -1,4 +1,14 @@ /** + * Primitive types for CLI parameter parsing. + * + * Primitives handle the low-level parsing of string input into typed values. + * Most users should use the higher-level `Argument` and `Flag` modules instead. + * + * This module is primarily useful for: + * - Creating custom primitive types + * - Understanding how CLI parsing works internally + * - Advanced customization of parsing behavior + * * @since 4.0.0 */ import * as Ini from "ini" @@ -418,7 +428,7 @@ export const redacted: Primitive> = makePrimitive( * import { Effect } from "effect" * * const readConfigFile = Effect.gen(function* () { - * const content = yield* Primitive.fileString.parse("./config.json") + * const content = yield* Primitive.fileText.parse("./config.json") * console.log(content) // File contents as string * * const parsed = JSON.parse(content) @@ -429,8 +439,8 @@ export const redacted: Primitive> = makePrimitive( * @since 4.0.0 * @category constructors */ -export const fileString: Primitive = makePrimitive( - "FileString", +export const fileText: Primitive = makePrimitive( + "FileText", Effect.fnUntraced(function*(filePath) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -484,7 +494,6 @@ export type FileParseOptions = { const fileParsers: Record unknown> = { ini: (content: string) => Ini.parse(content), json: (content: string) => JSON.parse(content), - tml: (content: string) => Toml.parse(content), toml: (content: string) => Toml.parse(content), yml: (content: string) => Yaml.parse(content), yaml: (content: string) => Yaml.parse(content) @@ -519,7 +528,7 @@ export const fileParse = (options?: FileParseOptions): Primitive => { if (parser === undefined) { return yield* Effect.fail(`Unsupported file format: ${fileFormat}`) } - const content = yield* fileString.parse(filePath) + const content = yield* fileText.parse(filePath) return yield* Effect.try({ try: () => parser(content), catch: (error) => `Failed to parse '.${fileFormat}' file content: ${error}` @@ -588,7 +597,7 @@ export const fileSchema = ( } /** - * Parses `key=value` pairs into a record object. + * Parses a single `key=value` pair into a record object. * * @example * ```ts @@ -596,13 +605,13 @@ export const fileSchema = ( * import { Effect } from "effect" * * const parseKeyValue = Effect.gen(function* () { - * const result1 = yield* Primitive.keyValueMap.parse("name=john") + * const result1 = yield* Primitive.keyValuePair.parse("name=john") * console.log(result1) // { name: "john" } * - * const result2 = yield* Primitive.keyValueMap.parse("port=3000") + * const result2 = yield* Primitive.keyValuePair.parse("port=3000") * console.log(result2) // { port: "3000" } * - * const result3 = yield* Primitive.keyValueMap.parse("debug=true") + * const result3 = yield* Primitive.keyValuePair.parse("debug=true") * console.log(result3) // { debug: "true" } * }) * ``` @@ -610,8 +619,8 @@ export const fileSchema = ( * @since 4.0.0 * @category constructors */ -export const keyValueMap: Primitive> = makePrimitive( - "KeyValueMap", +export const keyValuePair: Primitive> = makePrimitive( + "KeyValuePair", Effect.fnUntraced(function*(value) { const parts = value.split("=") if (parts.length !== 2) { @@ -629,6 +638,13 @@ export const keyValueMap: Primitive> = makePrimitive( }) ) +/** + * @deprecated Use `keyValuePair` instead. + * @since 4.0.0 + * @category constructors + */ +export const keyValueMap: Primitive> = keyValuePair + /** * A sentinel primitive that always fails to parse a value. * @@ -665,7 +681,7 @@ export const none: Primitive = makePrimitive("None", () => Effect.fail("T * console.log(Primitive.getTypeName(Primitive.integer)) // "integer" * console.log(Primitive.getTypeName(Primitive.boolean)) // "boolean" * console.log(Primitive.getTypeName(Primitive.date)) // "date" - * console.log(Primitive.getTypeName(Primitive.keyValueMap)) // "key=value" + * console.log(Primitive.getTypeName(Primitive.keyValuePair)) // "key=value" * * const logLevelChoice = Primitive.choice([ * ["debug", "debug"], @@ -695,11 +711,13 @@ export const getTypeName = (primitive: Primitive): string => { return "choice" case "Redacted": return "string" - case "FileString": + case "FileText": + return "file" + case "FileParse": return "file" case "FileSchema": return "file" - case "KeyValueMap": + case "KeyValuePair": return "key=value" case "None": return "none" diff --git a/packages/effect/src/unstable/cli/internal/command.ts b/packages/effect/src/unstable/cli/internal/command.ts index 922992a14..c1a6d7eb3 100644 --- a/packages/effect/src/unstable/cli/internal/command.ts +++ b/packages/effect/src/unstable/cli/internal/command.ts @@ -250,7 +250,7 @@ export const getHelpForCommandPath = ( command: Command, commandPath: ReadonlyArray ): HelpDoc => { - let currentCommand: Command = command as any + let currentCommand: Command.Any = command // Navigate through the command path to find the target command for (let i = 1; i < commandPath.length; i++) { diff --git a/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts b/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts index 1a21de1b3..8374d223a 100644 --- a/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts +++ b/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts @@ -65,6 +65,15 @@ interface CompletionItem { readonly description?: string } +const findMatchingFlag = ( + token: string, + flags: ReadonlyArray +): SingleFlagMeta | undefined => + flags.find((flag) => + token === `--${flag.name}` || + flag.aliases.some((a) => token === (a.length === 1 ? `-${a}` : `--${a}`)) + ) + const formatAlias = (alias: string): string => { if (alias.startsWith("-")) { return alias @@ -183,7 +192,7 @@ export const generateDynamicCompletions = ( } // Find the current command context by walking through the words - let currentCmd: Command = rootCmd as any + let currentCmd: Command.Any = rootCmd let wordIndex = 1 // Skip executable name // Walk through words to find the current command context @@ -202,23 +211,20 @@ export const generateDynamicCompletions = ( } const singles = getSingles(toImpl(currentCmd).config.flags) - const matchingOption = singles.find((s) => - optionToken === `--${s.name}` || - s.aliases.some((a) => optionToken === (a.length === 1 ? `-${a}` : `--${a}`)) - ) + const matchingFlag = findMatchingFlag(optionToken, singles) wordIndex++ // Move past the option if ( - matchingOption && optionRequiresValue(matchingOption) && !hasInlineValue && wordIndex < context.currentIndex + matchingFlag && optionRequiresValue(matchingFlag) && !hasInlineValue && wordIndex < context.currentIndex ) { wordIndex++ // Skip the option value } } else { // Check if it's a subcommand - const subCmd = currentCmd.subcommands.find((c: any) => c.name === word) + const subCmd = currentCmd.subcommands.find((c) => c.name === word) if (subCmd) { - currentCmd = subCmd as any + currentCmd = subCmd wordIndex++ } else { // Unknown word in command path - return empty completions @@ -235,13 +241,10 @@ export const generateDynamicCompletions = ( const equalIndex = currentWord.indexOf("=") if (currentWord.startsWith("-") && equalIndex !== -1) { const optionToken = currentWord.slice(0, equalIndex) - const matchingOption = singles.find((s) => - optionToken === `--${s.name}` || - s.aliases.some((a) => optionToken === (a.length === 1 ? `-${a}` : `--${a}`)) - ) + const matchingFlag = findMatchingFlag(optionToken, singles) - if (matchingOption && optionRequiresValue(matchingOption)) { - const candidateKind = matchingOption.typeName ?? (matchingOption.primitiveTag === "Path" ? "path" : undefined) + if (matchingFlag && optionRequiresValue(matchingFlag)) { + const candidateKind = matchingFlag.typeName ?? (matchingFlag.primitiveTag === "Path" ? "path" : undefined) const fileKind = candidateKind === "directory" || candidateKind === "file" || candidateKind === "either" || candidateKind === "path" ? candidateKind @@ -268,13 +271,10 @@ export const generateDynamicCompletions = ( if (prevWord && prevWord.startsWith("-")) { const prevEqIndex = prevWord.indexOf("=") const prevToken = prevEqIndex === -1 ? prevWord : prevWord.slice(0, prevEqIndex) - const matchingOption = singles.find((s) => - prevToken === `--${s.name}` || - s.aliases.some((a) => prevToken === (a.length === 1 ? `-${a}` : `--${a}`)) - ) + const matchingFlag = findMatchingFlag(prevToken, singles) - if (matchingOption && optionRequiresValue(matchingOption)) { - const candidateKind = matchingOption.typeName ?? (matchingOption.primitiveTag === "Path" ? "path" : undefined) + if (matchingFlag && optionRequiresValue(matchingFlag)) { + const candidateKind = matchingFlag.typeName ?? (matchingFlag.primitiveTag === "Path" ? "path" : undefined) const fileKind = candidateKind === "directory" || candidateKind === "file" || candidateKind === "either" || candidateKind === "path" ? candidateKind diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 32596a6fc..01356ad7b 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -129,7 +129,7 @@ export const parseArgs = ( /* Types */ /* ========================================================================== */ -type FlagParam = Param.Single +type FlagParam = Param.Single /** * Mutable map of flag names to their collected string values. diff --git a/packages/effect/test/unstable/cli/Arguments.test.ts b/packages/effect/test/unstable/cli/Arguments.test.ts index e001bbf29..f07fcb69a 100644 --- a/packages/effect/test/unstable/cli/Arguments.test.ts +++ b/packages/effect/test/unstable/cli/Arguments.test.ts @@ -1,5 +1,5 @@ import { assert, describe, expect, it } from "@effect/vitest" -import { Effect, Layer, Ref } from "effect" +import { Effect, Layer, Option, Ref, Result } from "effect" import { FileSystem, Path, PlatformError } from "effect/platform" import { TestConsole } from "effect/testing" import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli" @@ -173,4 +173,154 @@ describe("Command arguments", () => { assert.isDefined(result) assert.deepStrictEqual(result.files, ["file1.txt", "file2.txt", "file3.txt"]) }).pipe(Effect.provide(TestLayer))) + + it("should handle choiceWithValue", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + const testCommand = Command.make("test", { + level: Argument.choiceWithValue( + "level", + [ + ["debug", 0], + ["info", 1], + ["error", 2] + ] as const + ) + }, (config) => Ref.set(resultRef, config)) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["info"]) + const result = yield* Ref.get(resultRef) + assert.strictEqual(result.level, 1) + }).pipe(Effect.provide(TestLayer))) + + it("should handle filter combinator - valid", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + const testCommand = Command.make("test", { + port: Argument.integer("port").pipe( + Argument.filter( + (n) => n >= 1 && n <= 65535, + (n) => `Port ${n} out of range (1-65535)` + ) + ) + }, (config) => Ref.set(resultRef, config)) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["8080"]) + const result = yield* Ref.get(resultRef) + assert.strictEqual(result.port, 8080) + }).pipe(Effect.provide(TestLayer))) + + it("should handle filter combinator - invalid", () => + Effect.gen(function*() { + const testCommand = Command.make("test", { + port: Argument.integer("port").pipe( + Argument.filter( + (n) => n >= 1 && n <= 65535, + (n) => `Port ${n} out of range (1-65535)` + ) + ) + }, () => Effect.void) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["99999"]) + const stderr = yield* TestConsole.errorLines + assert.isTrue(stderr.some((line) => String(line).includes("out of range"))) + }).pipe(Effect.provide(TestLayer))) + + it("should handle filterMap combinator - valid", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + const testCommand = Command.make("test", { + positiveInt: Argument.integer("num").pipe( + Argument.filterMap( + (n) => n > 0 ? Option.some(n) : Option.none(), + (n) => `Expected positive integer, got ${n}` + ) + ) + }, (config) => Ref.set(resultRef, config)) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) + const result = yield* Ref.get(resultRef) + assert.strictEqual(result.positiveInt, 42) + }).pipe(Effect.provide(TestLayer))) + + it("should handle filterMap combinator - invalid", () => + Effect.gen(function*() { + const testCommand = Command.make("test", { + positiveInt: Argument.integer("num").pipe( + Argument.filterMap( + (n) => n > 0 ? Option.some(n) : Option.none(), + (n) => `Expected positive integer, got ${n}` + ) + ) + }, () => Effect.void) + + yield* Command.runWith(testCommand, { version: "1.0.0" })(["0"]) + const stderr = yield* TestConsole.errorLines + assert.isTrue(stderr.some((line) => String(line).includes("Expected positive integer"))) + }).pipe(Effect.provide(TestLayer))) + + it("should handle orElse combinator", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + // Try parsing as integer first, fallback to 0 + const testCommand = Command.make("test", { + value: Argument.integer("value").pipe( + Argument.orElse(() => Argument.string("value").pipe(Argument.map(() => -1))) + ) + }, (config) => Ref.set(resultRef, config)) + + // Valid integer + yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) + let result = yield* Ref.get(resultRef) + assert.strictEqual(result.value, 42) + + // Invalid integer, falls back to string path + yield* Ref.set(resultRef, null) + yield* Command.runWith(testCommand, { version: "1.0.0" })(["abc"]) + result = yield* Ref.get(resultRef) + assert.strictEqual(result.value, -1) + }).pipe(Effect.provide(TestLayer))) + + it("should handle orElseResult combinator", () => + Effect.gen(function*() { + const resultRef = yield* Ref.make(null) + + const testCommand = Command.make("test", { + value: Argument.integer("value").pipe( + Argument.orElseResult(() => Argument.string("value")) + ) + }, (config) => Ref.set(resultRef, config)) + + // Valid integer - returns Success + yield* Command.runWith(testCommand, { version: "1.0.0" })(["42"]) + let result = yield* Ref.get(resultRef) + assert.isTrue(Result.isSuccess(result.value)) + assert.strictEqual(result.value.value, 42) + + // Invalid integer - returns Failure with string + yield* Ref.set(resultRef, null) + yield* Command.runWith(testCommand, { version: "1.0.0" })(["abc"]) + result = yield* Ref.get(resultRef) + assert.isTrue(Result.isFailure(result.value)) + assert.strictEqual(result.value.value, "abc") + }).pipe(Effect.provide(TestLayer))) + + it("should handle withPseudoName combinator", () => + Effect.gen(function*() { + const testCommand = Command.make("test", { + file: Argument.string("file").pipe( + Argument.withPseudoName("FILE_PATH") + ) + }, () => Effect.void) + + // Run with help flag to check pseudo name appears in help + yield* Command.runWith(testCommand, { version: "1.0.0" })(["--help"]) + const stdout = yield* TestConsole.logLines + const helpText = stdout.join("\n") + assert.isTrue(helpText.includes("FILE_PATH")) + }).pipe(Effect.provide(TestLayer))) }) diff --git a/packages/effect/test/unstable/cli/Errors.test.ts b/packages/effect/test/unstable/cli/Errors.test.ts index d437042bb..4c0a241ce 100644 --- a/packages/effect/test/unstable/cli/Errors.test.ts +++ b/packages/effect/test/unstable/cli/Errors.test.ts @@ -2,7 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Layer } from "effect" import { FileSystem, Path } from "effect/platform" import { CliError, Command, Flag } from "effect/unstable/cli" -import { toImpl } from "effect/unstable/cli/Command" +import { toImpl } from "effect/unstable/cli/internal/command" import * as Lexer from "effect/unstable/cli/internal/lexer" import * as Parser from "effect/unstable/cli/internal/parser" import * as MockTerminal from "./services/MockTerminal.ts" From f59c550edad1762657037742891825028ed50918 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Nov 2025 12:34:51 -0500 Subject: [PATCH 23/29] refactor(cli): remove unnecessary generics from parser.ts - parseArgs now accepts Command.Any instead of being generic - Removed generics from CommandContext and internal functions - Eliminated `as unknown as` cast for subcommand recursion --- .../src/unstable/cli/internal/parser.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 01356ad7b..d54f79ca4 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -79,9 +79,9 @@ export const extractBuiltInOptions = ( }) /** @internal */ -export const parseArgs = ( +export const parseArgs = ( lexResult: LexResult, - command: Command, + command: Command.Any, commandPath: ReadonlyArray = [] ): Effect.Effect => Effect.gen(function*() { @@ -93,7 +93,7 @@ export const parseArgs = ( const flagParams = singles.filter(Param.isFlagParam) const flagRegistry = createFlagRegistry(flagParams) - const context: CommandContext = { + const context: CommandContext = { command, commandPath: newCommandPath, flagRegistry @@ -112,7 +112,7 @@ export const parseArgs = ( const subLex: LexResult = { tokens: result.childTokens, trailingOperands: [] } const subParsed = yield* parseArgs( subLex, - result.sub as unknown as Command, + result.sub, newCommandPath ) @@ -163,10 +163,9 @@ type FlagAccumulator = { /** * Context for parsing a command level. - * Generics flow through to avoid runtime casts. */ -type CommandContext = { - readonly command: Command +type CommandContext = { + readonly command: Command.Any readonly commandPath: ReadonlyArray readonly flagRegistry: FlagRegistry } @@ -477,10 +476,10 @@ const toLeafResult = (state: ParseState): LeafResult => ({ * - Return Argument to treat it as a positional argument * - Report error if command expects subcommand but got unknown value */ -const resolveFirstValue = ( +const resolveFirstValue = ( value: string, cursor: TokenCursor, - context: CommandContext, + context: CommandContext, state: ParseState ): FirstValueResult => { const { command, commandPath, flagRegistry } = context @@ -528,10 +527,10 @@ const resolveFirstValue = ( * Processes a flag token: looks up in registry, consumes value, records it. * Reports unrecognized flags as errors. */ -const processFlag = ( +const processFlag = ( token: FlagToken, cursor: TokenCursor, - context: CommandContext, + context: CommandContext, state: ParseState ): void => { const { commandPath, flagRegistry } = context @@ -557,10 +556,10 @@ const processFlag = ( * In CollectingArguments mode: * - Simply add value to arguments list */ -const processValue = ( +const processValue = ( value: string, cursor: TokenCursor, - context: CommandContext, + context: CommandContext, state: ParseState ): SubcommandResult | undefined => { if (state.mode._tag === "AwaitingFirstValue") { @@ -590,9 +589,9 @@ const processValue = ( * * Returns LeafResult if no subcommand detected, SubcommandResult otherwise. */ -const scanCommandLevel = ( +const scanCommandLevel = ( tokens: ReadonlyArray, - context: CommandContext + context: CommandContext ): LevelResult => { const cursor = makeCursor(tokens) const state = createParseState(context.flagRegistry) From 8fe2b29f86aff142da6c912ffc16ff873db19c9f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Nov 2025 13:00:59 -0500 Subject: [PATCH 24/29] refactor(cli): surface all errors to user, add formatErrors - Add formatErrors to CliOutput.Formatter for multiple error display - Update showHelp() to accept error array instead of single error - Pass all accumulated errors in runWith (was only showing first) - Improve createFlagRegistry error messages - Add comprehensive error tests --- packages/effect/src/unstable/cli/CliOutput.ts | 56 ++++++++++++++- packages/effect/src/unstable/cli/Command.ts | 10 +-- .../src/unstable/cli/internal/parser.ts | 8 ++- .../effect/test/unstable/cli/Errors.test.ts | 72 ++++++++++++++++++- 4 files changed, 134 insertions(+), 12 deletions(-) diff --git a/packages/effect/src/unstable/cli/CliOutput.ts b/packages/effect/src/unstable/cli/CliOutput.ts index 15c432503..698595390 100644 --- a/packages/effect/src/unstable/cli/CliOutput.ts +++ b/packages/effect/src/unstable/cli/CliOutput.ts @@ -151,6 +151,29 @@ export interface Formatter { * @since 4.0.0 */ readonly formatVersion: (name: string, version: string) => string + + /** + * Formats multiple CLI errors for display, grouping by error type. + * + * @example + * ```ts + * import { CliOutput, CliError } from "effect/unstable/cli" + * + * const formatter = CliOutput.defaultFormatter({ colors: false }) + * + * const errors = [ + * new CliError.UnrecognizedOption({ option: "--foo", suggestions: ["--force"] }), + * new CliError.UnrecognizedOption({ option: "--bar", suggestions: [] }), + * new CliError.MissingOption({ option: "--required" }) + * ] + * + * const output = formatter.formatErrors(errors) + * // Groups errors by type and displays all at once + * ``` + * + * @since 4.0.0 + */ + readonly formatErrors: (errors: ReadonlyArray) => string } /** @@ -294,15 +317,42 @@ export const defaultFormatter = (options?: { colors?: boolean }): Formatter => { magenta: (text: string): string => text } + const reset = useColor ? "\x1b[0m" : "" + const red = useColor ? "\x1b[31m" : "" + const bold = useColor ? "\x1b[1m" : "" + return { formatHelpDoc: (doc: HelpDoc): string => formatHelpDocImpl(doc, colors), formatCliError: (error): string => error.message, formatError: (error): string => { - const reset = useColor ? "\x1b[0m" : "" - const red = useColor ? "\x1b[31m" : "" - const bold = useColor ? "\x1b[1m" : "" return `\n${bold}${red}ERROR${reset}\n ${error.message}${reset}` }, + formatErrors: (errors): string => { + if (errors.length === 0) return "" + if (errors.length === 1) { + return `\n${bold}${red}ERROR${reset}\n ${errors[0].message}${reset}` + } + + // Group errors by _tag + const grouped = new Map>() + for (const error of errors) { + const tag = (error as any)._tag ?? "Error" + const group = grouped.get(tag) ?? [] + group.push(error) + grouped.set(tag, group) + } + + const sections: Array = [] + sections.push(`\n${bold}${red}ERRORS${reset}`) + + for (const [, group] of grouped) { + for (const error of group) { + sections.push(` ${error.message}${reset}`) + } + } + + return sections.join("\n") + }, formatVersion: (name: string, version: string): string => `${colors.bold(name)} ${colors.dim("v")}${colors.bold(version)}` } diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 30ca7530f..22ffe78f3 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -767,14 +767,14 @@ export const provideEffectDiscard: { const showHelp = ( command: Command, commandPath: ReadonlyArray, - error?: CliError.CliError + errors?: ReadonlyArray ): Effect.Effect => Effect.gen(function*() { const formatter = yield* CliOutput.Formatter const helpDoc = getHelpForCommandPath(command, commandPath) yield* Console.log(formatter.formatHelpDoc(helpDoc)) - if (error) { - yield* Console.error(formatter.formatError(error)) + if (errors && errors.length > 0) { + yield* Console.error(formatter.formatErrors(errors)) } }) @@ -900,11 +900,11 @@ export const runWith = ( // Handle parsing errors if (parsedArgs.errors && parsedArgs.errors.length > 0) { - return yield* showHelp(command, commandPath, parsedArgs.errors[0]) + return yield* showHelp(command, commandPath, parsedArgs.errors) } const parseResult = yield* Effect.result(commandImpl.parse(parsedArgs)) if (parseResult._tag === "Failure") { - return yield* showHelp(command, commandPath, parseResult.failure) + return yield* showHelp(command, commandPath, [parseResult.failure]) } const parsed = parseResult.success diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index d54f79ca4..2521be32b 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -250,20 +250,22 @@ const makeCursor = (tokens: ReadonlyArray): TokenCursor => { /** * Creates a registry for O(1) flag lookup by name or alias. - * @throws Error if duplicate names or aliases are detected + * @throws Error if duplicate names or aliases are detected (developer error) */ const createFlagRegistry = (params: ReadonlyArray): FlagRegistry => { const index = new Map() for (const param of params) { if (index.has(param.name)) { - throw new Error(`Duplicate flag name: ${param.name}`) + throw new Error(`Duplicate flag name "${param.name}" in command definition`) } index.set(param.name, param) for (const alias of param.aliases) { if (index.has(alias)) { - throw new Error(`Duplicate flag/alias: ${alias}`) + throw new Error( + `Duplicate flag/alias "${alias}" in command definition (conflicts with "${index.get(alias)!.name}")` + ) } index.set(alias, param) } diff --git a/packages/effect/test/unstable/cli/Errors.test.ts b/packages/effect/test/unstable/cli/Errors.test.ts index 4c0a241ce..bf93f7d13 100644 --- a/packages/effect/test/unstable/cli/Errors.test.ts +++ b/packages/effect/test/unstable/cli/Errors.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Layer } from "effect" import { FileSystem, Path } from "effect/platform" -import { CliError, Command, Flag } from "effect/unstable/cli" +import { CliError, CliOutput, Command, Flag } from "effect/unstable/cli" import { toImpl } from "effect/unstable/cli/internal/command" import * as Lexer from "effect/unstable/cli/internal/lexer" import * as Parser from "effect/unstable/cli/internal/parser" @@ -51,5 +51,75 @@ describe("Command errors", () => { assert.strictEqual(duplicate.childCommand, "child") } }) + + it.effect("accumulates multiple UnrecognizedOption errors", () => + Effect.gen(function*() { + const command = Command.make("test", { + verbose: Flag.boolean("verbose") + }) + + const parsedInput = yield* Parser.parseArgs( + Lexer.lex(["--unknown1", "--unknown2"]), + command + ) + + assert.isDefined(parsedInput.errors) + assert.strictEqual(parsedInput.errors!.length, 2) + assert.instanceOf(parsedInput.errors![0], CliError.UnrecognizedOption) + assert.instanceOf(parsedInput.errors![1], CliError.UnrecognizedOption) + }).pipe(Effect.provide(TestLayer))) + + it.effect("accumulates UnknownSubcommand error", () => + Effect.gen(function*() { + const sub = Command.make("deploy") + const command = Command.make("app").pipe( + Command.withSubcommands([sub]) + ) + + const parsedInput = yield* Parser.parseArgs( + Lexer.lex(["deplyo"]), + command + ) + + assert.isDefined(parsedInput.errors) + assert.strictEqual(parsedInput.errors!.length, 1) + assert.instanceOf(parsedInput.errors![0], CliError.UnknownSubcommand) + + const error = parsedInput.errors![0] as CliError.UnknownSubcommand + assert.strictEqual(error.subcommand, "deplyo") + assert.isTrue(error.suggestions.includes("deploy")) + }).pipe(Effect.provide(TestLayer))) + }) + + describe("formatErrors", () => { + it("formats single error with ERROR header", () => { + const formatter = CliOutput.defaultFormatter({ colors: false }) + const error = new CliError.MissingOption({ option: "value" }) + + const output = formatter.formatErrors([error]) + + assert.isTrue(output.includes("ERROR")) + assert.isTrue(output.includes("Missing required flag")) + }) + + it("formats multiple errors with ERRORS header", () => { + const formatter = CliOutput.defaultFormatter({ colors: false }) + const errors = [ + new CliError.UnrecognizedOption({ option: "--foo", suggestions: [] }), + new CliError.UnrecognizedOption({ option: "--bar", suggestions: [] }) + ] + + const output = formatter.formatErrors(errors) + + assert.isTrue(output.includes("ERRORS")) + assert.isTrue(output.includes("--foo")) + assert.isTrue(output.includes("--bar")) + }) + + it("returns empty string for empty array", () => { + const formatter = CliOutput.defaultFormatter({ colors: false }) + const output = formatter.formatErrors([]) + assert.strictEqual(output, "") + }) }) }) From bf8aef33bf53225a975e369785305a33b761f637 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Nov 2025 14:08:23 -0500 Subject: [PATCH 25/29] refactor(cli): naming consistency cleanup - findMatchingFlag -> lookupFlag - SingleFlagMeta -> FlagDescriptor - parseOption -> parseFlag - Add Primitive.isBoolean() helper --- packages/effect/src/unstable/cli/Param.ts | 6 +++--- packages/effect/src/unstable/cli/Primitive.ts | 3 +++ .../internal/completions/dynamic/handler.ts | 20 +++++++++---------- .../cli/internal/completions/index.ts | 2 +- .../cli/internal/completions/shared.ts | 4 ++-- .../cli/internal/completions/types.ts | 4 ++-- .../src/unstable/cli/internal/parser.ts | 6 +++--- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index 713d947fa..2b194d1dd 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -281,7 +281,7 @@ export const makeSingle = (params: { const parse: Parse = (args) => params.kind === Argument ? parsePositional(params.name, params.primitiveType, args) - : parseOption(params.name, params.primitiveType, args) + : parseFlag(params.name, params.primitiveType, args) return Object.assign(Object.create(Proto), { _tag: "Single", ...params, @@ -1589,7 +1589,7 @@ const parsePositional: ( return [args.arguments.slice(1), value] as const }) -const parseOption: ( +const parseFlag: ( name: string, primitiveType: Primitive.Primitive, args: ParsedArgs @@ -1602,7 +1602,7 @@ const parseOption: ( if (providedValues === undefined || providedValues.length === 0) { // Option not provided (empty array due to initialization) - if (primitiveType._tag === "Boolean") { + if (Primitive.isBoolean(primitiveType)) { // Boolean params default to false when not present return [args.arguments, false as any] as const } else { diff --git a/packages/effect/src/unstable/cli/Primitive.ts b/packages/effect/src/unstable/cli/Primitive.ts index dc723ee81..a95085b32 100644 --- a/packages/effect/src/unstable/cli/Primitive.ts +++ b/packages/effect/src/unstable/cli/Primitive.ts @@ -94,6 +94,9 @@ export const isTrueValue = Schema.is(Config.TrueValues) /** @internal */ export const isFalseValue = Schema.is(Config.FalseValues) +/** @internal */ +export const isBoolean = (p: Primitive): p is Primitive => p._tag === "Boolean" + const makePrimitive = ( tag: string, parse: ( diff --git a/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts b/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts index 8374d223a..435929c99 100644 --- a/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts +++ b/packages/effect/src/unstable/cli/internal/completions/dynamic/handler.ts @@ -7,7 +7,7 @@ import type { Command } from "../../../Command.ts" import { toImpl } from "../../command.ts" import { getSingles } from "../shared.ts" import { optionRequiresValue } from "../types.ts" -import type { SingleFlagMeta } from "../types.ts" +import type { FlagDescriptor } from "../types.ts" interface CompletionContext { readonly words: ReadonlyArray @@ -65,10 +65,10 @@ interface CompletionItem { readonly description?: string } -const findMatchingFlag = ( +const lookupFlag = ( token: string, - flags: ReadonlyArray -): SingleFlagMeta | undefined => + flags: ReadonlyArray +): FlagDescriptor | undefined => flags.find((flag) => token === `--${flag.name}` || flag.aliases.some((a) => token === (a.length === 1 ? `-${a}` : `--${a}`)) @@ -81,7 +81,7 @@ const formatAlias = (alias: string): string => { return alias.length === 1 ? `-${alias}` : `--${alias}` } -const getTypeLabel = (flag: SingleFlagMeta): string | undefined => { +const getTypeLabel = (flag: FlagDescriptor): string | undefined => { if (flag.typeName) { switch (flag.typeName) { case "directory": @@ -112,7 +112,7 @@ const getTypeLabel = (flag: SingleFlagMeta): string | undefined => { } } -const buildFlagDescription = (flag: SingleFlagMeta): string => { +const buildFlagDescription = (flag: FlagDescriptor): string => { const parts: Array = [] const aliasParts = flag.aliases .map(formatAlias) @@ -136,7 +136,7 @@ const buildFlagDescription = (flag: SingleFlagMeta): string => { const addFlagCandidates = ( addItem: (item: CompletionItem) => void, - flag: SingleFlagMeta, + flag: FlagDescriptor, query: string, includeAliases: boolean ) => { @@ -211,7 +211,7 @@ export const generateDynamicCompletions = ( } const singles = getSingles(toImpl(currentCmd).config.flags) - const matchingFlag = findMatchingFlag(optionToken, singles) + const matchingFlag = lookupFlag(optionToken, singles) wordIndex++ // Move past the option @@ -241,7 +241,7 @@ export const generateDynamicCompletions = ( const equalIndex = currentWord.indexOf("=") if (currentWord.startsWith("-") && equalIndex !== -1) { const optionToken = currentWord.slice(0, equalIndex) - const matchingFlag = findMatchingFlag(optionToken, singles) + const matchingFlag = lookupFlag(optionToken, singles) if (matchingFlag && optionRequiresValue(matchingFlag)) { const candidateKind = matchingFlag.typeName ?? (matchingFlag.primitiveTag === "Path" ? "path" : undefined) @@ -271,7 +271,7 @@ export const generateDynamicCompletions = ( if (prevWord && prevWord.startsWith("-")) { const prevEqIndex = prevWord.indexOf("=") const prevToken = prevEqIndex === -1 ? prevWord : prevWord.slice(0, prevEqIndex) - const matchingFlag = findMatchingFlag(prevToken, singles) + const matchingFlag = lookupFlag(prevToken, singles) if (matchingFlag && optionRequiresValue(matchingFlag)) { const candidateKind = matchingFlag.typeName ?? (matchingFlag.primitiveTag === "Path" ? "path" : undefined) diff --git a/packages/effect/src/unstable/cli/internal/completions/index.ts b/packages/effect/src/unstable/cli/internal/completions/index.ts index e82070303..4fefaba84 100644 --- a/packages/effect/src/unstable/cli/internal/completions/index.ts +++ b/packages/effect/src/unstable/cli/internal/completions/index.ts @@ -22,4 +22,4 @@ export { } from "./dynamic/index.ts" /** @internal */ -export type { Shell, SingleFlagMeta } from "./types.ts" +export type { Shell, FlagDescriptor } from "./types.ts" diff --git a/packages/effect/src/unstable/cli/internal/completions/shared.ts b/packages/effect/src/unstable/cli/internal/completions/shared.ts index baec46eb7..5200ec202 100644 --- a/packages/effect/src/unstable/cli/internal/completions/shared.ts +++ b/packages/effect/src/unstable/cli/internal/completions/shared.ts @@ -1,8 +1,8 @@ import * as Param from "../../Param.ts" -import type { SingleFlagMeta } from "./types.ts" +import type { FlagDescriptor } from "./types.ts" /** @internal */ -export const getSingles = (flags: ReadonlyArray): ReadonlyArray => +export const getSingles = (flags: ReadonlyArray): ReadonlyArray => flags .flatMap(Param.extractSingleParams) .filter((s) => s.kind === "flag") diff --git a/packages/effect/src/unstable/cli/internal/completions/types.ts b/packages/effect/src/unstable/cli/internal/completions/types.ts index 90a1a907c..7e60d4be8 100644 --- a/packages/effect/src/unstable/cli/internal/completions/types.ts +++ b/packages/effect/src/unstable/cli/internal/completions/types.ts @@ -2,7 +2,7 @@ export type Shell = "bash" | "zsh" | "fish" /** @internal */ -export interface SingleFlagMeta { +export interface FlagDescriptor { readonly name: string readonly aliases: ReadonlyArray readonly primitiveTag: string @@ -11,4 +11,4 @@ export interface SingleFlagMeta { } /** @internal */ -export const optionRequiresValue = (s: SingleFlagMeta): boolean => s.primitiveTag !== "Boolean" +export const optionRequiresValue = (s: FlagDescriptor): boolean => s.primitiveTag !== "Boolean" diff --git a/packages/effect/src/unstable/cli/internal/parser.ts b/packages/effect/src/unstable/cli/internal/parser.ts index 2521be32b..b4867689a 100644 --- a/packages/effect/src/unstable/cli/internal/parser.ts +++ b/packages/effect/src/unstable/cli/internal/parser.ts @@ -32,7 +32,7 @@ import type { Path } from "../../../platform/Path.ts" import * as CliError from "../CliError.ts" import type { Command, ParsedTokens } from "../Command.ts" import * as Param from "../Param.ts" -import { isFalseValue, isTrueValue } from "../Primitive.ts" +import * as Primitive from "../Primitive.ts" import { suggest } from "./auto-suggest.ts" import { completionsFlag, helpFlag, logLevelFlag, versionFlag } from "./builtInFlags.ts" import { toImpl } from "./command.ts" @@ -326,7 +326,7 @@ const getFlagName = (t: FlagToken): string => t._tag === "LongOption" ? t.name : * Recognizes: true/false, yes/no, on/off, 1/0 */ const asBooleanLiteral = (token: Token | undefined): string | undefined => - token?._tag === "Value" && (isTrueValue(token.value) || isFalseValue(token.value)) + token?._tag === "Value" && (Primitive.isTrueValue(token.value) || Primitive.isFalseValue(token.value)) ? token.value : undefined @@ -353,7 +353,7 @@ const consumeFlagValue = ( } // Boolean flags: check for explicit literal or default to "true" - if (spec.primitiveType._tag === "Boolean") { + if (Primitive.isBoolean(spec.primitiveType)) { const literal = asBooleanLiteral(cursor.peek()) if (literal !== undefined) cursor.take() return literal ?? "true" From 098211a5328881a8be02bd5572bc5cf77bb86733 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Nov 2025 14:17:32 -0500 Subject: [PATCH 26/29] refactor(cli): simplify variance struct pattern --- packages/effect/src/unstable/cli/Param.ts | 12 +++--------- packages/effect/src/unstable/cli/Primitive.ts | 12 +++--------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index 2b194d1dd..76c24fa0f 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -116,15 +116,9 @@ export declare namespace Param { * @category models */ export interface Variance extends Pipeable { - readonly [TypeId]: VarianceStruct - } - - /** - * @since 4.0.0 - * @category models - */ - export interface VarianceStruct { - readonly _A: Covariant + readonly [TypeId]: { + readonly _A: Covariant + } } } diff --git a/packages/effect/src/unstable/cli/Primitive.ts b/packages/effect/src/unstable/cli/Primitive.ts index a95085b32..e19670c9d 100644 --- a/packages/effect/src/unstable/cli/Primitive.ts +++ b/packages/effect/src/unstable/cli/Primitive.ts @@ -70,15 +70,9 @@ export declare namespace Primitive { * @category models */ export interface Variance { - readonly [TypeId]: VarianceStruct - } - - /** - * @since 4.0.0 - * @category models - */ - export interface VarianceStruct { - readonly _A: Covariant + readonly [TypeId]: { + readonly _A: Covariant + } } } From 829475bd7bf81eedde9d42035794166cdfd91a69 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Nov 2025 14:29:30 -0500 Subject: [PATCH 27/29] refactor(cli): use unknown instead of any in .Any types --- packages/effect/src/unstable/cli/Command.ts | 4 ++-- packages/effect/src/unstable/cli/Param.ts | 6 +++--- packages/effect/src/unstable/cli/Prompt.ts | 2 +- .../effect/src/unstable/cli/internal/completions/index.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 22ffe78f3..7afcf1e72 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -97,7 +97,7 @@ export interface Command exten /** * The subcommands available under this command. */ - readonly subcommands: ReadonlyArray> + readonly subcommands: ReadonlyArray } /** @@ -200,7 +200,7 @@ export declare namespace Command { * @since 4.0.0 * @category models */ - export type Any = Command + export type Any = Command } /** diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index 76c24fa0f..38faf4f73 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -79,7 +79,7 @@ export const Flag: "flag" = flagKind * @since 4.0.0 * @category models */ -export type Any = Param +export type Any = Param /** * Represents any positional argument parameter. @@ -87,7 +87,7 @@ export type Any = Param * @since 4.0.0 * @category models */ -export type AnyArgument = Param +export type AnyArgument = Param /** * Represents any flag parameter. @@ -95,7 +95,7 @@ export type AnyArgument = Param * @since 4.0.0 * @category models */ -export type AnyFlag = Param +export type AnyFlag = Param /** * @since 4.0.0 diff --git a/packages/effect/src/unstable/cli/Prompt.ts b/packages/effect/src/unstable/cli/Prompt.ts index 3f3d3a452..94e4a675a 100644 --- a/packages/effect/src/unstable/cli/Prompt.ts +++ b/packages/effect/src/unstable/cli/Prompt.ts @@ -494,7 +494,7 @@ export const platformFigures = Effect.map( * @since 4.0.0 * @category utility types */ -export type Any = Prompt +export type Any = Prompt /** * @since 4.0.0 diff --git a/packages/effect/src/unstable/cli/internal/completions/index.ts b/packages/effect/src/unstable/cli/internal/completions/index.ts index 4fefaba84..5fe6023e4 100644 --- a/packages/effect/src/unstable/cli/internal/completions/index.ts +++ b/packages/effect/src/unstable/cli/internal/completions/index.ts @@ -22,4 +22,4 @@ export { } from "./dynamic/index.ts" /** @internal */ -export type { Shell, FlagDescriptor } from "./types.ts" +export type { FlagDescriptor, Shell } from "./types.ts" From 05f23f9dad98bc520815b6096ef012a3c2e8d5be Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Nov 2025 15:18:42 -0500 Subject: [PATCH 28/29] refactor(cli): remove deprecated exports, normalize JSDoc categories - Remove deprecated: keyValueMap, repeated, withPseudoName, Argument/Flag constants - Fix AnyArgument/AnyFlag to use argumentKind/flagKind - Make CliError.TypeId internal - Normalize @category tags to lowercase --- packages/effect/src/unstable/cli/Argument.ts | 23 ------- packages/effect/src/unstable/cli/CliError.ts | 24 +++---- packages/effect/src/unstable/cli/Flag.ts | 36 ----------- packages/effect/src/unstable/cli/Param.ts | 64 ++----------------- packages/effect/src/unstable/cli/Primitive.ts | 7 -- .../test/unstable/cli/Arguments.test.ts | 6 +- .../effect/test/unstable/cli/Command.test.ts | 6 +- 7 files changed, 22 insertions(+), 144 deletions(-) diff --git a/packages/effect/src/unstable/cli/Argument.ts b/packages/effect/src/unstable/cli/Argument.ts index c4f90c2e5..a193bc902 100644 --- a/packages/effect/src/unstable/cli/Argument.ts +++ b/packages/effect/src/unstable/cli/Argument.ts @@ -427,22 +427,6 @@ export const mapTryCatch: { onError: (error: unknown) => string ) => Param.mapTryCatch(self, f, onError)) -/** - * Creates a variadic argument that accepts multiple values (same as variadic). - * - * @example - * ```ts - * import { Argument } from "effect/unstable/cli" - * - * const files = Argument.string("files").pipe(Argument.repeated) - * ``` - * - * @since 4.0.0 - * @category combinators - * @deprecated Use `variadic` instead. `repeated` is equivalent to `variadic` with no options. - */ -export const repeated = (arg: Argument): Argument> => Param.repeated(arg) - /** * Creates a variadic argument that requires at least n values. * @@ -568,13 +552,6 @@ export const withMetavar: { (self: Argument, metavar: string): Argument } = dual(2, (self: Argument, metavar: string) => Param.withMetavar(self, metavar)) -/** - * @deprecated Use `withMetavar` instead. - * @since 4.0.0 - * @category metadata - */ -export const withPseudoName = withMetavar - /** * Filters parsed values, failing with a custom error message if the predicate returns false. * diff --git a/packages/effect/src/unstable/cli/CliError.ts b/packages/effect/src/unstable/cli/CliError.ts index ba9385f3c..5c23205ff 100644 --- a/packages/effect/src/unstable/cli/CliError.ts +++ b/packages/effect/src/unstable/cli/CliError.ts @@ -6,9 +6,9 @@ import * as Schema from "../../schema/Schema.ts" /** * @since 4.0.0 - * @category TypeId + * @category type id */ -export const TypeId = "~effect/cli/CliError" +const TypeId = "~effect/cli/CliError" /** * Type guard to check if a value is a CLI error. @@ -37,7 +37,7 @@ export const TypeId = "~effect/cli/CliError" * ``` * * @since 4.0.0 - * @category Guards + * @category guards */ export const isCliError = (u: unknown): u is CliError => Predicate.hasProperty(u, TypeId) @@ -71,7 +71,7 @@ export const isCliError = (u: unknown): u is CliError => Predicate.hasProperty(u * ``` * * @since 4.0.0 - * @category Models + * @category models */ export type CliError = | UnrecognizedOption @@ -113,7 +113,7 @@ export type CliError = * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class UnrecognizedOption extends Schema.ErrorClass(`${TypeId}/UnrecognizedOption`)({ _tag: Schema.tag("UnrecognizedOption"), @@ -159,7 +159,7 @@ export class UnrecognizedOption extends Schema.ErrorClass(`${TypeId}/Unrecognize * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class DuplicateOption extends Schema.ErrorClass(`${TypeId}/DuplicateOption`)({ _tag: Schema.tag("DuplicateOption"), @@ -207,7 +207,7 @@ export class DuplicateOption extends Schema.ErrorClass(`${TypeId}/DuplicateOptio * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class MissingOption extends Schema.ErrorClass(`${TypeId}/MissingOption`)({ _tag: Schema.tag("MissingOption"), @@ -251,7 +251,7 @@ export class MissingOption extends Schema.ErrorClass(`${TypeId}/MissingOption`)( * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class MissingArgument extends Schema.ErrorClass(`${TypeId}/MissingArgument`)({ _tag: Schema.tag("MissingArgument"), @@ -298,7 +298,7 @@ export class MissingArgument extends Schema.ErrorClass(`${TypeId}/MissingArgumen * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class InvalidValue extends Schema.ErrorClass(`${TypeId}/InvalidValue`)({ _tag: Schema.tag("InvalidValue"), @@ -351,7 +351,7 @@ export class InvalidValue extends Schema.ErrorClass(`${TypeId}/InvalidValue`)({ * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class UnknownSubcommand extends Schema.ErrorClass(`${TypeId}/UnknownSubcommand`)({ _tag: Schema.tag("UnknownSubcommand"), @@ -412,7 +412,7 @@ export class UnknownSubcommand extends Schema.ErrorClass(`${TypeId}/UnknownSubco * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class ShowHelp extends Schema.ErrorClass(`${TypeId}/ShowHelp`)({ _tag: Schema.tag("ShowHelp"), @@ -464,7 +464,7 @@ export class ShowHelp extends Schema.ErrorClass(`${TypeId}/ShowHelp`)({ * ``` * * @since 4.0.0 - * @category Models + * @category models */ export class UserError extends Schema.ErrorClass(`${TypeId}/UserError`)({ _tag: Schema.tag("UserError"), diff --git a/packages/effect/src/unstable/cli/Flag.ts b/packages/effect/src/unstable/cli/Flag.ts index 76007a8cf..df30c5796 100644 --- a/packages/effect/src/unstable/cli/Flag.ts +++ b/packages/effect/src/unstable/cli/Flag.ts @@ -339,13 +339,6 @@ export const fileSchema = ( */ export const keyValuePair = (name: string): Flag> => Param.keyValuePair(Param.flagKind, name) -/** - * @deprecated Use `keyValuePair` instead. - * @since 4.0.0 - * @category constructors - */ -export const keyValueMap = keyValuePair - /** * Creates an empty sentinel flag that always fails to parse. * This is useful for creating placeholder flags or for combinators. @@ -452,13 +445,6 @@ export const withMetavar: { (self: Flag, metavar: string): Flag } = dual(2, (self: Flag, metavar: string) => Param.withMetavar(self, metavar)) -/** - * @deprecated Use `withMetavar` instead. - * @since 4.0.0 - * @category metadata - */ -export const withPseudoName = withMetavar - /** * Makes a flag optional, returning an Option type that can be None if not provided. * @@ -611,28 +597,6 @@ export const mapTryCatch: { onError: (error: unknown) => string ) => Param.mapTryCatch(self, f, onError)) -/** - * Allows a flag to be specified multiple times, collecting all values into an array. - * - * @example - * ```ts - * import { Flag } from "effect/unstable/cli" - * - * const includeFlag = Flag.repeated(Flag.file("include")) - * // Usage: --include file1.ts --include file2.ts --include file3.ts - * // Result: ["file1.ts", "file2.ts", "file3.ts"] - * - * const verbosityFlag = Flag.repeated(Flag.boolean("verbose")) - * // Usage: --verbose --verbose --verbose - * // Result: [true, true, true] - * ``` - * - * @deprecated Use `Flag.atLeast(flag, 0)` or `Flag.between(flag, min, max)` instead. - * @since 4.0.0 - * @category repetition - */ -export const repeated = (flag: Flag): Flag> => Param.repeated(flag) - /** * Requires a flag to be specified at least a minimum number of times. * diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index 38faf4f73..19d639f37 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -59,20 +59,6 @@ export const argumentKind: "argument" = "argument" as const */ export const flagKind: "flag" = "flag" as const -/** - * @deprecated Use `argumentKind` instead. - * @since 4.0.0 - * @category constants - */ -export const Argument: "argument" = argumentKind - -/** - * @deprecated Use `flagKind` instead. - * @since 4.0.0 - * @category constants - */ -export const Flag: "flag" = flagKind - /** * Represents any parameter. * @@ -87,7 +73,7 @@ export type Any = Param * @since 4.0.0 * @category models */ -export type AnyArgument = Param +export type AnyArgument = Param /** * Represents any flag parameter. @@ -95,7 +81,7 @@ export type AnyArgument = Param * @since 4.0.0 * @category models */ -export type AnyFlag = Param +export type AnyFlag = Param /** * @since 4.0.0 @@ -258,7 +244,7 @@ export const isSingle = ( */ export const isFlagParam = ( single: Single -): single is Single => single.kind === "flag" +): single is Single => single.kind === "flag" /** * @since 4.0.0 @@ -273,7 +259,7 @@ export const makeSingle = (params: { readonly aliases?: ReadonlyArray | undefined }): Single => { const parse: Parse = (args) => - params.kind === Argument + params.kind === argumentKind ? parsePositional(params.name, params.primitiveType, args) : parseFlag(params.name, params.primitiveType, args) return Object.assign(Object.create(Proto), { @@ -772,13 +758,6 @@ export const keyValuePair = ( (objects) => Object.assign({}, ...objects) ) -/** - * @deprecated Use `keyValuePair` instead. - * @since 4.0.0 - * @category constructors - */ -export const keyValueMap = keyValuePair - /** * Creates an empty sentinel parameter that always fails to parse. * @@ -1214,34 +1193,6 @@ export const between: { return variadic(self, { min, max }) }) -/** - * Wraps an option to allow it to be specified multiple times without limit. - * - * This combinator transforms an option to accept any number of occurrences - * on the command line, returning an array of all provided values. - * - * @example - * ```ts - * // @internal - this module is not exported publicly - * - * // Allow unlimited file inputs - * const files = Param.string(Param.flagKind, "file").pipe( - * Param.repeated, - * Param.withAlias("-f") - * ) - * - * // Parse: --file a.txt --file b.txt --file c.txt --file d.txt - * // Result: ["a.txt", "b.txt", "c.txt", "d.txt"] - * ``` - * - * @since 4.0.0 - * @category combinators - * @deprecated Use `variadic` instead. `repeated` is equivalent to `variadic` with no options. - */ -export const repeated = ( - self: Param -): Param> => variadic(self) - /** * Wraps an option to allow it to be specified at most `max` times. * @@ -1426,13 +1377,6 @@ export const withMetavar: { typeName: metavar }))) -/** - * @deprecated Use `withMetavar` instead. - * @since 4.0.0 - * @category metadata - */ -export const withPseudoName = withMetavar - /** * Validates parsed values against a Schema, providing detailed error messages. * diff --git a/packages/effect/src/unstable/cli/Primitive.ts b/packages/effect/src/unstable/cli/Primitive.ts index e19670c9d..f74840bdb 100644 --- a/packages/effect/src/unstable/cli/Primitive.ts +++ b/packages/effect/src/unstable/cli/Primitive.ts @@ -635,13 +635,6 @@ export const keyValuePair: Primitive> = makePrimitive( }) ) -/** - * @deprecated Use `keyValuePair` instead. - * @since 4.0.0 - * @category constructors - */ -export const keyValueMap: Primitive> = keyValuePair - /** * A sentinel primitive that always fails to parse a value. * diff --git a/packages/effect/test/unstable/cli/Arguments.test.ts b/packages/effect/test/unstable/cli/Arguments.test.ts index f07fcb69a..0fcd89d44 100644 --- a/packages/effect/test/unstable/cli/Arguments.test.ts +++ b/packages/effect/test/unstable/cli/Arguments.test.ts @@ -158,7 +158,7 @@ describe("Command arguments", () => { let result: { readonly files: ReadonlyArray } | undefined const testCommand = Command.make("test", { - files: Argument.string("files").pipe(Argument.repeated) + files: Argument.string("files").pipe(Argument.variadic) }, (config) => Effect.sync(() => { result = config @@ -309,11 +309,11 @@ describe("Command arguments", () => { assert.strictEqual(result.value.value, "abc") }).pipe(Effect.provide(TestLayer))) - it("should handle withPseudoName combinator", () => + it("should handle withMetavar combinator", () => Effect.gen(function*() { const testCommand = Command.make("test", { file: Argument.string("file").pipe( - Argument.withPseudoName("FILE_PATH") + Argument.withMetavar("FILE_PATH") ) }, () => Effect.void) diff --git a/packages/effect/test/unstable/cli/Command.test.ts b/packages/effect/test/unstable/cli/Command.test.ts index 2771b32e6..af022c025 100644 --- a/packages/effect/test/unstable/cli/Command.test.ts +++ b/packages/effect/test/unstable/cli/Command.test.ts @@ -107,7 +107,7 @@ describe("Command", () => { const captured: Array> = [] const command = Command.make("env", { - env: Flag.keyValueMap("env") + env: Flag.keyValuePair("env") }, (config) => Effect.sync(() => { captured.push(config.env) @@ -132,7 +132,7 @@ describe("Command", () => { const captured: Array> = [] const command = Command.make("env", { - env: Flag.keyValueMap("env"), + env: Flag.keyValuePair("env"), verbose: Flag.boolean("verbose"), profile: Flag.string("profile") }, (config) => @@ -168,7 +168,7 @@ describe("Command", () => { let invoked = false const command = Command.make("env", { - env: Flag.keyValueMap("env") + env: Flag.keyValuePair("env") }, () => Effect.sync(() => { invoked = true From 800892b5dee6f587aac189e637d4a4c3a1fa9aea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 29 Nov 2025 12:49:42 -0500 Subject: [PATCH 29/29] fix errors --- packages/effect/src/unstable/cli/CliOutput.ts | 6 +- packages/effect/src/unstable/cli/Command.ts | 28 ++++++-- packages/effect/src/unstable/cli/Param.ts | 67 +++++++++++++++++++ .../test/unstable/cli/Arguments.test.ts | 9 +-- 4 files changed, 97 insertions(+), 13 deletions(-) diff --git a/packages/effect/src/unstable/cli/CliOutput.ts b/packages/effect/src/unstable/cli/CliOutput.ts index 698595390..3445c61e5 100644 --- a/packages/effect/src/unstable/cli/CliOutput.ts +++ b/packages/effect/src/unstable/cli/CliOutput.ts @@ -21,7 +21,8 @@ import type { HelpDoc } from "./HelpDoc.ts" * formatHelpDoc: (doc) => `Custom Help: ${doc.usage}`, * formatCliError: (error) => `Error: ${error.message}`, * formatError: (error) => `[ERROR] ${error.message}`, - * formatVersion: (name, version) => `${name} (${version})` + * formatVersion: (name, version) => `${name} (${version})`, + * formatErrors: (errors) => errors.map((error) => error.message).join("\\n") * } * * // Use the custom formatter in a program @@ -235,7 +236,8 @@ export const Formatter: ServiceMap.Reference = ServiceMap.Reference( * formatHelpDoc: (doc) => JSON.stringify(doc, null, 2), * formatCliError: (error) => JSON.stringify({ error: error.message }), * formatError: (error) => JSON.stringify({ type: "error", message: error.message }), - * formatVersion: (name, version) => JSON.stringify({ name, version }) + * formatVersion: (name, version) => JSON.stringify({ name, version }), + * formatErrors: (errors) => JSON.stringify(errors.map((error) => error.message)) * } * const JsonLayer = CliOutput.layer(jsonFormatter) * ``` diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index 7afcf1e72..bdbd70622 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -114,17 +114,18 @@ export declare namespace Command { * * @example * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" + * import type * as CliCommand from "effect/unstable/cli/Command" + * import { Argument, Flag } from "effect/unstable/cli" * * // Simple flat configuration - * const simpleConfig: Command.Config = { + * const simpleConfig: CliCommand.Command.Config = { * name: Flag.string("name"), * age: Flag.integer("age"), * file: Argument.string("file") * } * * // Nested configuration for organization - * const nestedConfig: Command.Config = { + * const nestedConfig: CliCommand.Command.Config = { * user: { * name: Flag.string("name"), * email: Flag.string("email") @@ -146,6 +147,12 @@ export declare namespace Command { | Config } + /** + * Utilities for working with command configurations. + * + * @since 4.0.0 + * @category models + */ export namespace Config { /** * Infers the TypeScript type from a Command.Config structure. @@ -155,7 +162,8 @@ export declare namespace Command { * * @example * ```ts - * import { Command, Flag, Argument } from "effect/unstable/cli" + * import type * as CliCommand from "effect/unstable/cli/Command" + * import { Argument, Flag } from "effect/unstable/cli" * * const config = { * name: Flag.string("name"), @@ -165,7 +173,7 @@ export declare namespace Command { * } * } as const * - * type Result = Command.Config.Infer + * type Result = CliCommand.Command.Config.Infer * // { * // readonly name: string * // readonly server: { @@ -639,7 +647,7 @@ const mapHandler = ( * ```ts * import { Command, Flag } from "effect/unstable/cli" * import { Effect, Layer } from "effect" - * import { FileSystem } from "effect/platform" + * import { FileSystem, PlatformError } from "effect/platform" * * const deploy = Command.make("deploy", { * env: Flag.string("env") @@ -653,7 +661,13 @@ const mapHandler = ( * Command.provide((config) => * config.env === "local" * ? FileSystem.layerNoop({}) - * : FileSystem.layer + * : FileSystem.layerNoop({ + * access: () => + * Effect.fail(new PlatformError.BadArgument({ + * module: "FileSystem", + * method: "access" + * })) + * }) * ) * ) * ``` diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index 19d639f37..8787cd132 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -202,6 +202,8 @@ const Proto = { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const maybeParam = Param.string(Param.flagKind, "name") @@ -221,6 +223,8 @@ export const isParam = (u: unknown): u is Param => Predicate.has * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const nameParam = Param.string(Param.flagKind, "name") @@ -276,6 +280,8 @@ export const makeSingle = (params: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Create a string flag @@ -305,6 +311,8 @@ export const string = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Create a boolean flag @@ -335,6 +343,8 @@ export const boolean = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Create an integer flag @@ -364,6 +374,8 @@ export const integer = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Create a float flag @@ -393,6 +405,8 @@ export const float = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Create a date flag @@ -424,6 +438,8 @@ export const date = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * type Animal = Dog | Cat @@ -461,6 +477,8 @@ export const choiceWithValue = < * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const logLevel = Param.choice(Param.flagKind, "log-level", [ @@ -487,6 +505,8 @@ export const choice = < * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Basic path parameter @@ -530,6 +550,8 @@ export const path = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Basic directory parameter @@ -565,6 +587,8 @@ export const directory = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Basic file parameter @@ -598,6 +622,8 @@ export const file = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Create a password parameter @@ -627,6 +653,8 @@ export const redacted = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Read a config file as string @@ -656,6 +684,8 @@ export const fileText = (kind: Kind, name: string): Para * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Will use the extension of the file passed on the command line to determine @@ -686,6 +716,7 @@ export const fileParse = ( * @example * ```ts * import { Schema } from "effect/schema" + * import * as Param from "effect/unstable/cli/Param" * // @internal - this module is not exported publicly * * // Parse JSON config file @@ -730,6 +761,8 @@ export const fileSchema = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const env = Param.keyValuePair(Param.flagKind, "env") @@ -765,6 +798,8 @@ export const keyValuePair = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Create a none parameter for composition @@ -799,6 +834,8 @@ const FLAG_DASH_REGEX = /^-+/ * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const force = Param.boolean(Param.flagKind, "force").pipe( @@ -835,6 +872,8 @@ export const withAlias: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const verbose = Param.boolean(Param.flagKind, "verbose").pipe( @@ -862,6 +901,8 @@ export const withDescription: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const port = Param.integer(Param.flagKind, "port").pipe( @@ -895,6 +936,8 @@ export const map: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * import { CliError } from "effect/unstable/cli" * import { Effect } from "effect" @@ -947,6 +990,8 @@ export const mapEffect: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const parsedJson = Param.string(Param.flagKind, "config").pipe( @@ -1015,6 +1060,8 @@ export const mapTryCatch: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Create an optional port option @@ -1053,6 +1100,8 @@ export const optional = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Using the pipe operator to make an option optional @@ -1105,6 +1154,8 @@ export type VariadicParamOptions = { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Basic variadic parameter (0 to infinity) @@ -1156,6 +1207,8 @@ export const variadic = ( * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Allow 1-3 file inputs @@ -1201,6 +1254,8 @@ export const between: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Allow at most 3 warning suppressions @@ -1233,6 +1288,8 @@ export const atMost: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * // Require at least 2 input files @@ -1267,6 +1324,7 @@ export const atLeast: { * @example * ```ts * import { Option } from "effect/data" + * import * as Param from "effect/unstable/cli/Param" * // @internal - this module is not exported publicly * * const positiveInt = Param.integer(Param.flagKind, "count").pipe( @@ -1316,6 +1374,8 @@ export const filterMap: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const evenNumber = Param.integer(Param.flagKind, "num").pipe( @@ -1353,6 +1413,8 @@ export const filter: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const port = Param.integer(Param.flagKind, "port").pipe( @@ -1383,6 +1445,7 @@ export const withMetavar: { * @example * ```ts * import { Schema } from "effect/schema" + * import * as Param from "effect/unstable/cli/Param" * // @internal - this module is not exported publicly * * const isEmail = Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) @@ -1423,6 +1486,8 @@ export const withSchema: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const config = Param.file(Param.flagKind, "config").pipe( @@ -1456,6 +1521,8 @@ export const orElse: { * * @example * ```ts + * import * as Param from "effect/unstable/cli/Param" + * * // @internal - this module is not exported publicly * * const configSource = Param.file(Param.flagKind, "config").pipe( diff --git a/packages/effect/test/unstable/cli/Arguments.test.ts b/packages/effect/test/unstable/cli/Arguments.test.ts index 0fcd89d44..47f8087be 100644 --- a/packages/effect/test/unstable/cli/Arguments.test.ts +++ b/packages/effect/test/unstable/cli/Arguments.test.ts @@ -1,5 +1,6 @@ import { assert, describe, expect, it } from "@effect/vitest" -import { Effect, Layer, Option, Ref, Result } from "effect" +import { Effect, Layer, Ref } from "effect" +import { Option, Result } from "effect/data" import { FileSystem, Path, PlatformError } from "effect/platform" import { TestConsole } from "effect/testing" import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli" @@ -158,10 +159,10 @@ describe("Command arguments", () => { let result: { readonly files: ReadonlyArray } | undefined const testCommand = Command.make("test", { - files: Argument.string("files").pipe(Argument.variadic) - }, (config) => + files: Argument.variadic(Argument.string("files")) + }, (parsedConfig) => Effect.sync(() => { - result = config + result = parsedConfig })) yield* Command.runWith(testCommand, { version: "1.0.0" })([